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
This commit is contained in:
medievalshell
2026-05-28 15:29:42 +02:00
parent 238592cd72
commit 9ece87240e
7 changed files with 142 additions and 8 deletions
@@ -76,6 +76,7 @@ export class RoomObjectVariable
public static FURNITURE_BRANDING_OFFSET_X: string = 'furniture_branding_offset_x';
public static FURNITURE_BRANDING_OFFSET_Y: string = 'furniture_branding_offset_y';
public static FURNITURE_BRANDING_OFFSET_Z: string = 'furniture_branding_offset_z';
public static FURNITURE_BRANDING_SCALE: string = 'furniture_branding_scale';
public static FURNITURE_BADGE_IMAGE_STATUS: string = 'furniture_badge_image_status';
public static FURNITURE_BADGE_ASSET_NAME: string = 'furniture_badge_asset_name';
public static FURNITURE_BADGE_VISIBLE_IN_STATE: string = 'furniture_badge_visible_in_state';
@@ -11,6 +11,7 @@ export interface IRoomObjectSprite
texture: Texture;
width: number;
height: number;
scale: number;
offsetX: number;
offsetY: number;
flipH: boolean;
+46 -1
View File
@@ -200,7 +200,11 @@ export class AssetManager implements IAssetManager
}
else
{
const texture = await Assets.load<Texture>(url);
// 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);
}
@@ -213,6 +217,47 @@ export class AssetManager implements IAssetManager
}
}
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');
@@ -12,6 +12,7 @@ export class FurnitureRoomBrandingLogic extends FurnitureLogic
public static OFFSETX_KEY: string = 'offsetX';
public static OFFSETY_KEY: string = 'offsetY';
public static OFFSETZ_KEY: string = 'offsetZ';
public static SCALE_KEY: string = 'scale';
protected _disableFurnitureSelection: boolean;
protected _hasClickUrl: boolean;
@@ -93,6 +94,10 @@ export class FurnitureRoomBrandingLogic extends FurnitureLogic
if(!isNaN(offsetY)) this.object.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_Y, offsetY);
if(!isNaN(offsetZ)) this.object.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_Z, offsetZ);
const scaleRaw = parseInt(objectData.getValue(FurnitureRoomBrandingLogic.SCALE_KEY));
const scale = isNaN(scaleRaw) ? 100 : scaleRaw;
this.object.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_SCALE, scale);
let options = (((FurnitureRoomBrandingLogic.IMAGEURL_KEY + '=') + ((imageUrl !== null) ? imageUrl : '')) + '\t');
if(this._hasClickUrl) options = (options + (((FurnitureRoomBrandingLogic.CLICKURL_KEY + '=') + ((clickUrl !== null) ? clickUrl : '')) + '\t'));
@@ -100,6 +105,7 @@ export class FurnitureRoomBrandingLogic extends FurnitureLogic
options = (options + (((FurnitureRoomBrandingLogic.OFFSETX_KEY + '=') + offsetX) + '\t'));
options = (options + (((FurnitureRoomBrandingLogic.OFFSETY_KEY + '=') + offsetY) + '\t'));
options = (options + (((FurnitureRoomBrandingLogic.OFFSETZ_KEY + '=') + offsetZ) + '\t'));
options = (options + (((FurnitureRoomBrandingLogic.SCALE_KEY + '=') + scale) + '\t'));
this.object.model.setValue(RoomWidgetEnumItemExtradataParameter.INFOSTAND_EXTRA_PARAM, (RoomWidgetEnumItemExtradataParameter.BRANDING_OPTIONS + options));
}
@@ -147,10 +153,25 @@ export class FurnitureRoomBrandingLogic extends FurnitureLogic
if(!texture)
{
const status = await asset.downloadAsset(imageUrl);
if(!status)
// downloadAsset THROWS on failure (it doesn't return false), and this
// method is fire-and-forget — so without this try/catch a failed image
// download just vanishes and the furni keeps showing its default. Catch
// it, surface the real reason, and flag the failed state.
try
{
const status = await asset.downloadAsset(imageUrl);
if(!status)
{
this.processUpdateMessage(new ObjectAdUpdateMessage(ObjectAdUpdateMessage.IMAGE_LOADING_FAILED));
return;
}
}
catch(error)
{
console.error(`[Soundboard/Branding] failed to load branding image "${ imageUrl }":`, error);
this.processUpdateMessage(new ObjectAdUpdateMessage(ObjectAdUpdateMessage.IMAGE_LOADING_FAILED));
return;
@@ -13,6 +13,7 @@ export class RoomObjectSprite implements IRoomObjectSprite
private _width: number = 0;
private _height: number = 0;
private _scale: number = 1;
private _offsetX: number = 0;
private _offsetY: number = 0;
private _flipH: boolean = false;
@@ -121,6 +122,21 @@ export class RoomObjectSprite implements IRoomObjectSprite
return this._height;
}
// Per-sprite zoom multiplier (1 = native). Applied on top of the room zoom.
public get scale(): number
{
return this._scale;
}
public set scale(value: number)
{
if(this._scale === value) return;
this._scale = value;
this._updateCounter++;
}
public get offsetX(): number
{
return this._offsetX;
@@ -18,6 +18,7 @@ export class FurnitureBrandedImageVisualization extends FurnitureVisualization
protected _offsetX: number;
protected _offsetY: number;
protected _offsetZ: number;
protected _imageScale: number;
protected _currentFrame: number;
protected _totalFrames: number;
@@ -32,6 +33,7 @@ export class FurnitureBrandedImageVisualization extends FurnitureVisualization
this._offsetX = 0;
this._offsetY = 0;
this._offsetZ = 0;
this._imageScale = 100;
this._currentFrame = -1;
this._totalFrames = -1;
}
@@ -65,6 +67,7 @@ export class FurnitureBrandedImageVisualization extends FurnitureVisualization
this._offsetX = (this.object.model.getValue<number>(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_X) || 0);
this._offsetY = (this.object.model.getValue<number>(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_Y) || 0);
this._offsetZ = (this.object.model.getValue<number>(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_Z) || 0);
this._imageScale = (this.object.model.getValue<number>(RoomObjectVariable.FURNITURE_BRANDING_SCALE) || 100);
}
if(!this._imageReady)
@@ -207,4 +210,49 @@ export class FurnitureBrandedImageVisualization extends FurnitureVisualization
return super.getSpriteAssetName(scale, layerId);
}
private isBrandedImageLayer(scale: number, layerId: number): boolean
{
return this.getLayerTag(scale, this._direction, layerId) === FurnitureBrandedImageVisualization.BRANDED_IMAGE;
}
// offsetX/Y move ONLY the branded image layer (the furni frame stays put).
// Authored at full scale (64) and scaled with the room zoom.
protected getLayerXOffset(scale: number, direction: number, layerId: number): number
{
const offset = super.getLayerXOffset(scale, direction, layerId);
return this.isBrandedImageLayer(scale, layerId) ? offset + ((this._offsetX || 0) * (scale / 64)) : offset;
}
protected getLayerYOffset(scale: number, direction: number, layerId: number): number
{
const offset = super.getLayerYOffset(scale, direction, layerId);
return this.isBrandedImageLayer(scale, layerId) ? offset + ((this._offsetY || 0) * (scale / 64)) : offset;
}
// offsetZ is the z-index / depth (overlap order, like CSS z-index).
protected getLayerZOffset(scale: number, direction: number, layerId: number): number
{
const offset = super.getLayerZOffset(scale, direction, layerId);
return this.isBrandedImageLayer(scale, layerId) ? offset + (this._offsetZ || 0) : offset;
}
// `scale` zooms the branded image (percent, 100 = 1x). Applied via the
// sprite's scale (a real Pixi sprite scale) — NOT by writing width/height,
// which are read-only on RoomObjectSprite and would throw every frame.
protected updateSprite(scale: number, layerId: number): void
{
super.updateSprite(scale, layerId);
const sprite = this.getSprite(layerId);
if(!sprite) return;
sprite.scale = this.isBrandedImageLayer(scale, layerId)
? Math.max(0.1, (this._imageScale || 100) / 100)
: 1;
}
}
@@ -702,11 +702,13 @@ export class RoomSpriteCanvas implements IRoomRenderingCanvas
if(extendedSprite.texture !== objectSprite.texture) extendedSprite.setTexture(objectSprite.texture);
const sx = Math.abs(extendedSprite.scale.x) || 1;
const sy = Math.abs(extendedSprite.scale.y) || 1;
// Per-sprite zoom (objectSprite.scale, default 1) combined with flip.
// Setting the magnitude directly (instead of reading the previous
// scale) avoids compounding across frames.
const magnitude = (objectSprite.scale && (objectSprite.scale > 0)) ? objectSprite.scale : 1;
extendedSprite.scale.x = objectSprite.flipH ? -sx : sx;
extendedSprite.scale.y = objectSprite.flipV ? -sy : sy;
extendedSprite.scale.x = objectSprite.flipH ? -magnitude : magnitude;
extendedSprite.scale.y = objectSprite.flipV ? -magnitude : magnitude;
}
extendedSprite.x = Math.round(sprite.x);