import { IGraphicAsset, IRoomObjectSprite, RoomObjectVariable } from '@nitrots/api'; import { GetConfiguration } from '@nitrots/configuration'; import { GetSessionDataManager } from '@nitrots/session'; import { AnimatedSprite, Texture } from 'pixi.js'; import { parseGIF, decompressFrames } from 'gifuct-js'; import { FurnitureAnimatedVisualization } from './FurnitureAnimatedVisualization'; export class FurnitureBadgeDisplayVisualization extends FurnitureAnimatedVisualization { private static readonly BADGE_TAG = 'BADGE'; private static readonly BADGE_LAYER_ID = 1; private _badgeId = ''; private _badgeAssetNameNormalScale = ''; private _badgeAssetNameSmallScale = ''; private _badgeVisibleInState = -1; private _frameTextures: Texture[] = null; private _animatedSprite: AnimatedSprite = null; private _lastFrameIndex = -1; public dispose(): void { this.disposeAnimatedSprite(); super.dispose(); } private disposeAnimatedSprite(): void { if(this._animatedSprite) { this._animatedSprite.stop(); this._animatedSprite.destroy(); this._animatedSprite = null; } if(this._frameTextures) { for(const texture of this._frameTextures) { texture.destroy(true); } this._frameTextures = null; } this._lastFrameIndex = -1; } public getTexture(scale: number, layerId: number, asset: IGraphicAsset): Texture { return super.getTexture(scale, layerId, asset); } public update(geometry: any, time: number, update: boolean, skipUpdate: boolean): void { super.update(geometry, time, update, skipUpdate); // Update animated GIF using AnimatedSprite's current frame if(this._animatedSprite && this._frameTextures && this._frameTextures.length > 1) { const currentFrameIndex = this._animatedSprite.currentFrame; // Only update if frame has changed if(currentFrameIndex !== this._lastFrameIndex) { this._lastFrameIndex = currentFrameIndex; const sessionDataManager = GetSessionDataManager(); let tex = sessionDataManager.getBadgeImage(this._badgeId); if(!tex) tex = sessionDataManager.getGroupBadgeImage(this._badgeId); if(!tex) return; const currentFrameTexture = this._frameTextures[currentFrameIndex]; if(!currentFrameTexture) return; // Update the badge canvas with the current frame const badgeCanvas = (tex.source as any).resource as HTMLCanvasElement; const ctx = badgeCanvas.getContext('2d', { willReadFrequently: true }); // Create a temporary canvas to extract the frame texture data const frameCanvas = document.createElement('canvas'); frameCanvas.width = currentFrameTexture.width; frameCanvas.height = currentFrameTexture.height; const frameCtx = frameCanvas.getContext('2d'); // Get the source from the texture const frameSource = (currentFrameTexture.source as any).resource; if(frameSource instanceof HTMLCanvasElement || frameSource instanceof HTMLImageElement) { ctx.clearRect(0, 0, badgeCanvas.width, badgeCanvas.height); ctx.drawImage(frameSource, 0, 0); tex.source.update(); const assetName = this._badgeAssetNameNormalScale; if(this.asset && assetName) { const asset = this.getAsset(assetName, FurnitureBadgeDisplayVisualization.BADGE_LAYER_ID); if(asset && asset.texture) { asset.texture.source.update(); } } } } } } protected updateModel(scale: number): boolean { let needsUpdate = super.updateModel(scale); const badgeStatus = this.object.model.getValue(RoomObjectVariable.FURNITURE_BADGE_IMAGE_STATUS); const badgeId = this.object.model.getValue(RoomObjectVariable.FURNITURE_BADGE_ASSET_NAME); if(badgeStatus === -1) { this._badgeId = ''; this._badgeAssetNameNormalScale = ''; this._badgeAssetNameSmallScale = ''; this._badgeVisibleInState = -1; this.disposeAnimatedSprite(); return needsUpdate; } if((badgeStatus === 1) && badgeId && (badgeId !== this._badgeId)) { this._badgeId = badgeId; this._badgeAssetNameNormalScale = badgeId; this._badgeAssetNameSmallScale = `${badgeId}_32`; const visibleInState = this.object.model.getValue(RoomObjectVariable.FURNITURE_BADGE_VISIBLE_IN_STATE); this._badgeVisibleInState = isNaN(visibleInState) ? -1 : visibleInState; this.disposeAnimatedSprite(); this.addBadgeToAssetCollection(badgeId); const layerId = FurnitureBadgeDisplayVisualization.BADGE_LAYER_ID; this._assetNames[layerId] = undefined; this._updatedLayers[layerId] = undefined; this._spriteTags[layerId] = undefined; this.updateObjectCounter = -1; needsUpdate = true; } return needsUpdate; } protected getSpriteAssetName(scale: number, layerId: number): string { const tag = this.getLayerTag(scale, this.direction, layerId); if(tag !== FurnitureBadgeDisplayVisualization.BADGE_TAG) { return super.getSpriteAssetName(scale, layerId); } if((this._badgeVisibleInState !== -1) && (this.object.getState(0) !== this._badgeVisibleInState)) { return super.getSpriteAssetName(scale, layerId); } const assetName = (scale === 32) ? this._badgeAssetNameSmallScale : this._badgeAssetNameNormalScale; if(!assetName) return super.getSpriteAssetName(scale, layerId); const a = this.getAsset(assetName, layerId); if(!a || !a.texture) return super.getSpriteAssetName(scale, layerId); return assetName; } protected updateSprite(sprite: IRoomObjectSprite, asset: IGraphicAsset, scale: number, layerId: number): void { super.updateSprite(sprite, asset, scale, layerId); const tag = this.getLayerTag(scale, this.direction, layerId); if(tag === FurnitureBadgeDisplayVisualization.BADGE_TAG) { sprite.visible = true; sprite.alpha = 255; sprite.color = 0xFFFFFF; } } protected getLayerXOffset(scale: number, direction: number, layerId: number): number { let offset = super.getLayerXOffset(scale, direction, layerId); if(this.getLayerTag(scale, direction, layerId) === FurnitureBadgeDisplayVisualization.BADGE_TAG) { const assetName = (scale === 32) ? this._badgeAssetNameSmallScale : this._badgeAssetNameNormalScale; if(!assetName) return offset; const a = this.getAsset(assetName, layerId); if(!a) return offset; const targetW = (scale === 64) ? 40 : 20; offset += ((targetW - a.width) / 2); } return offset; } protected getLayerYOffset(scale: number, direction: number, layerId: number): number { let offset = super.getLayerYOffset(scale, direction, layerId); if(this.getLayerTag(scale, direction, layerId) === FurnitureBadgeDisplayVisualization.BADGE_TAG) { const assetName = (scale === 32) ? this._badgeAssetNameSmallScale : this._badgeAssetNameNormalScale; if(!assetName) return offset; const a = this.getAsset(assetName, layerId); if(!a) return offset; const targetH = (scale === 64) ? 40 : 20; offset += ((targetH - a.height) / 2); } return offset; } private async addBadgeToAssetCollection(badgeId: string): Promise { const sessionDataManager = GetSessionDataManager(); let tex = sessionDataManager.getBadgeImage(badgeId); if(!tex) tex = sessionDataManager.getGroupBadgeImage(badgeId); if(!tex || !this.asset) return; const badgeCanvas = (tex.source as any).resource as HTMLCanvasElement; const ctx = badgeCanvas.getContext('2d', { willReadFrequently: true }); const imageData = ctx.getImageData(0, 0, 1, 1); const isEmpty = imageData.data[3] === 0; if(isEmpty || !this._frameTextures) { const badgeUrl = GetConfiguration().getValue('badge.asset.url', '').replace('%badgename%', badgeId); try { const response = await fetch(badgeUrl); const arrayBuffer = await response.arrayBuffer(); const gif = parseGIF(arrayBuffer); const frames = decompressFrames(gif, true); if(frames && frames.length > 0) { this._frameTextures = []; const frameDelays: number[] = []; const accCanvas = document.createElement('canvas'); accCanvas.width = gif.lsd.width; accCanvas.height = gif.lsd.height; const accCtx = accCanvas.getContext('2d', { willReadFrequently: true }); for(let i = 0; i < frames.length; i++) { const frame = frames[i]; if(i > 0) { const prevDisposal = frames[i - 1].disposalType; if(prevDisposal === 2) { accCtx.clearRect(0, 0, gif.lsd.width, gif.lsd.height); } } const tempCanvas = document.createElement('canvas'); tempCanvas.width = frame.dims.width; tempCanvas.height = frame.dims.height; const tempCtx = tempCanvas.getContext('2d'); const patchData = new ImageData( new Uint8ClampedArray(frame.patch), frame.dims.width, frame.dims.height ); tempCtx.putImageData(patchData, 0, 0); accCtx.drawImage(tempCanvas, frame.dims.left, frame.dims.top); tempCanvas.width = 0; tempCanvas.height = 0; // Create a new canvas for this frame and create a texture from it const frameCanvas = document.createElement('canvas'); frameCanvas.width = gif.lsd.width; frameCanvas.height = gif.lsd.height; const frameCtx = frameCanvas.getContext('2d'); frameCtx.drawImage(accCanvas, 0, 0); // Create texture from canvas const frameTexture = Texture.from(frameCanvas); this._frameTextures.push(frameTexture); // GIF delays are in centiseconds (1/100th of a second) frameDelays.push(frame.delay || 10); } accCanvas.width = 0; accCanvas.height = 0; // Create AnimatedSprite with frame textures if(this._frameTextures.length > 1) { this._animatedSprite = new AnimatedSprite(this._frameTextures); // Calculate average delay and set animation speed const avgDelay = frameDelays.reduce((a, b) => a + b, 0) / frameDelays.length; // Convert centiseconds to seconds, then to animation speed // AnimatedSprite.animationSpeed is frames per ticker update (60fps default) // delay in centiseconds * 10 = milliseconds, / 1000 = seconds // At 60fps, 1 frame = ~16.67ms const delayMs = (avgDelay > 50 ? 10 : avgDelay) * 10; const framesPerTick = 16.67 / delayMs; this._animatedSprite.animationSpeed = framesPerTick; this._animatedSprite.loop = true; this._animatedSprite.play(); this._lastFrameIndex = -1; } // Draw first frame to the badge canvas const firstFrameSource = (this._frameTextures[0].source as any).resource; if(firstFrameSource instanceof HTMLCanvasElement) { ctx.clearRect(0, 0, badgeCanvas.width, badgeCanvas.height); ctx.drawImage(firstFrameSource, 0, 0); tex.source.update(); } } } catch(err) { console.error('Failed to parse GIF, using static image:', err); const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { ctx.clearRect(0, 0, badgeCanvas.width, badgeCanvas.height); ctx.drawImage(img, 0, 0, badgeCanvas.width, badgeCanvas.height); tex.source.update(); img.onload = null; img.onerror = null; }; img.onerror = () => { img.onload = null; img.onerror = null; }; img.src = badgeUrl; } } this.asset.addAsset(badgeId, tex, true, 0, 0, false, false); } }