From 9ece87240e8f53c37a8e0ed43498193aa5573216 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 15:29:42 +0200 Subject: [PATCH 1/3] feat: branding furni image position + scale (MPU background editor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + Texture.from instead of Assets.load (which didn't load cross-origin images); branding image download failures are now surfaced instead of swallowed --- .../nitro/room/object/RoomObjectVariable.ts | 1 + .../object/visualization/IRoomObjectSprite.ts | 1 + packages/assets/src/AssetManager.ts | 47 +++++++++++++++++- .../furniture/FurnitureRoomBrandingLogic.ts | 27 +++++++++-- .../object/visualization/RoomObjectSprite.ts | 16 +++++++ .../FurnitureBrandedImageVisualization.ts | 48 +++++++++++++++++++ .../room/src/renderer/RoomSpriteCanvas.ts | 10 ++-- 7 files changed, 142 insertions(+), 8 deletions(-) diff --git a/packages/api/src/nitro/room/object/RoomObjectVariable.ts b/packages/api/src/nitro/room/object/RoomObjectVariable.ts index 5c66dc1..e50525c 100644 --- a/packages/api/src/nitro/room/object/RoomObjectVariable.ts +++ b/packages/api/src/nitro/room/object/RoomObjectVariable.ts @@ -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'; diff --git a/packages/api/src/room/object/visualization/IRoomObjectSprite.ts b/packages/api/src/room/object/visualization/IRoomObjectSprite.ts index d5342d8..2066c17 100644 --- a/packages/api/src/room/object/visualization/IRoomObjectSprite.ts +++ b/packages/api/src/room/object/visualization/IRoomObjectSprite.ts @@ -11,6 +11,7 @@ export interface IRoomObjectSprite texture: Texture; width: number; height: number; + scale: number; offsetX: number; offsetY: number; flipH: boolean; diff --git a/packages/assets/src/AssetManager.ts b/packages/assets/src/AssetManager.ts index a9d652a..6e29525 100644 --- a/packages/assets/src/AssetManager.ts +++ b/packages/assets/src/AssetManager.ts @@ -200,7 +200,11 @@ export class AssetManager implements IAssetManager } else { - const texture = await Assets.load(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 + 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 + { + return new Promise((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 { const roomDataModule = await import('./assets/room/room.asset.json'); diff --git a/packages/room/src/object/logic/furniture/FurnitureRoomBrandingLogic.ts b/packages/room/src/object/logic/furniture/FurnitureRoomBrandingLogic.ts index cf935da..c6ee96a 100644 --- a/packages/room/src/object/logic/furniture/FurnitureRoomBrandingLogic.ts +++ b/packages/room/src/object/logic/furniture/FurnitureRoomBrandingLogic.ts @@ -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; diff --git a/packages/room/src/object/visualization/RoomObjectSprite.ts b/packages/room/src/object/visualization/RoomObjectSprite.ts index efeb808..4995b4d 100644 --- a/packages/room/src/object/visualization/RoomObjectSprite.ts +++ b/packages/room/src/object/visualization/RoomObjectSprite.ts @@ -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; diff --git a/packages/room/src/object/visualization/furniture/FurnitureBrandedImageVisualization.ts b/packages/room/src/object/visualization/furniture/FurnitureBrandedImageVisualization.ts index 5c312be..cde6c65 100644 --- a/packages/room/src/object/visualization/furniture/FurnitureBrandedImageVisualization.ts +++ b/packages/room/src/object/visualization/furniture/FurnitureBrandedImageVisualization.ts @@ -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(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_X) || 0); this._offsetY = (this.object.model.getValue(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_Y) || 0); this._offsetZ = (this.object.model.getValue(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_Z) || 0); + this._imageScale = (this.object.model.getValue(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; + } } diff --git a/packages/room/src/renderer/RoomSpriteCanvas.ts b/packages/room/src/renderer/RoomSpriteCanvas.ts index e3036bd..3480be7 100644 --- a/packages/room/src/renderer/RoomSpriteCanvas.ts +++ b/packages/room/src/renderer/RoomSpriteCanvas.ts @@ -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); From b127501c52f89aaac3778f41f78d2aae24ea1aa0 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Fri, 29 May 2026 00:45:05 +0200 Subject: [PATCH 2/3] fix: restore room-background Z transparency after branding offsetZ change FurnitureBrandedImageVisualization now adds offsetZ to the branded layer (z-index for the MPU/billboard editor). The room background uses offsetZ as an inverse depth push (the 'play with Z to hide floor/walls' trick); its getLayerZOffset subtracted offsetZ assuming the parent did not add it, so the two cancelled out and the effect was lost. Cancel the parent's +offsetZ for the branded layer to restore the original net (base - offsetZ). --- .../furniture/FurnitureRoomBackgroundVisualization.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/room/src/object/visualization/furniture/FurnitureRoomBackgroundVisualization.ts b/packages/room/src/object/visualization/furniture/FurnitureRoomBackgroundVisualization.ts index 558a6db..a2c0d39 100644 --- a/packages/room/src/object/visualization/furniture/FurnitureRoomBackgroundVisualization.ts +++ b/packages/room/src/object/visualization/furniture/FurnitureRoomBackgroundVisualization.ts @@ -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; } From c3b15f02bf2a2de4a223e31d0f80008260e5f923 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Sat, 30 May 2026 00:14:44 +0200 Subject: [PATCH 3/3] perf(gamedata): manifest ext by JSON mode, no double-probe tryFetchManifestPair sceglie l'estensione in base a resolveJsonMode(): json5 -> .json5, legacy -> .json, auto -> entrambi. Evita le richieste manifest.json fallite a ogni avvio in modalita json5. --- packages/utils/src/GamedataLoader.ts | 16 +++++++++++++--- packages/utils/src/JsonParser.ts | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/utils/src/GamedataLoader.ts b/packages/utils/src/GamedataLoader.ts index cf2d794..80df1cb 100644 --- a/packages/utils/src/GamedataLoader.ts +++ b/packages/utils/src/GamedataLoader.ts @@ -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 (url: string): Promise => } }; -// 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 .json5 +// legacy -> only .json +// auto -> try .json5 first, fall back to .json +// All treated as optional (a clean 404 -> null); anything else bubbles up. const tryFetchManifestPair = async (baseUrl: string, name: string): Promise => { + const mode = resolveJsonMode(); + + if(mode === 'json5') return tryFetchManifest(joinUrl(baseUrl, `${ name }.json5`)); + + if(mode === 'legacy') return tryFetchManifest(joinUrl(baseUrl, `${ name }.json`)); + const json5 = await tryFetchManifest(joinUrl(baseUrl, `${ name }.json5`)); if(json5 !== null) return json5; diff --git a/packages/utils/src/JsonParser.ts b/packages/utils/src/JsonParser.ts index 46f3631..af9f857 100644 --- a/packages/utils/src/JsonParser.ts +++ b/packages/utils/src/JsonParser.ts @@ -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 {