Merge remote-tracking branch 'upstream/main'

This commit is contained in:
github-actions[bot]
2026-05-31 03:35:27 +00:00
10 changed files with 162 additions and 12 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;
}
}
@@ -76,6 +76,12 @@ export class FurnitureRoomBackgroundVisualization extends FurnitureBrandedImageV
if(this.getLayerTag(scale, direction, layerId) === FurnitureBrandedImageVisualization.BRANDED_IMAGE)
{
// The parent (FurnitureBrandedImageVisualization) now ADDS offsetZ to the
// branded layer as a z-index (for the MPU/billboard editor). The room
// background instead uses offsetZ as an INVERSE depth push — the classic
// "play with Z to make the floor/walls go transparent" trick — so cancel
// the parent's +offsetZ to restore the original net (base - offsetZ).
zOffset += (-(this._offsetZ));
zOffset += FurnitureRoomBackgroundVisualization.BRANDED_IMAGE_LAYER_DEPTH_BIAS;
}
@@ -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);
+13 -3
View File
@@ -1,4 +1,4 @@
import { ConfigJsonError, fetchConfigJson, isMissingResource } from './JsonParser';
import { ConfigJsonError, fetchConfigJson, isMissingResource, resolveJsonMode } from './JsonParser';
import { NitroLogger } from './NitroLogger';
export const DEFAULT_TIERS = [ 'core', 'custom', 'seasonal' ] as const;
@@ -45,10 +45,20 @@ const tryFetchManifest = async <T = any>(url: string): Promise<T | null> =>
}
};
// Try .json5 first, then .json — both treated as optional. Anything other
// than 404 on either bubbles up.
// Pick the manifest extension from the active JSON mode instead of always
// probing both — that just doubles the failed requests on startup.
// json5 -> only <name>.json5
// legacy -> only <name>.json
// auto -> try .json5 first, fall back to .json
// All treated as optional (a clean 404 -> null); anything else bubbles up.
const tryFetchManifestPair = async <T = any>(baseUrl: string, name: string): Promise<T | null> =>
{
const mode = resolveJsonMode();
if(mode === 'json5') return tryFetchManifest<T>(joinUrl(baseUrl, `${ name }.json5`));
if(mode === 'legacy') return tryFetchManifest<T>(joinUrl(baseUrl, `${ name }.json`));
const json5 = await tryFetchManifest<T>(joinUrl(baseUrl, `${ name }.json5`));
if(json5 !== null) return json5;
+1 -1
View File
@@ -27,7 +27,7 @@ export class ConfigJsonError extends Error
export const isMissingResource = (err: unknown): boolean =>
err instanceof ConfigJsonError && err.phase === 'fetch' && err.httpStatus === 404;
const resolveJsonMode = (): 'legacy' | 'json5' | 'auto' =>
export const resolveJsonMode = (): 'legacy' | 'json5' | 'auto' =>
{
try
{