import { Application } from "@pixi/app"; import { BaseTexture, Rectangle, Texture } from "@pixi/core"; import { Sprite } from "@pixi/sprite"; export type UiAnimationClip = { name: string; fps: number; frames: number[]; one_shot: boolean; }; export type UiSpritePack = { id: string; frame_width: number; frame_height: number; atlas_data_url: string; animations: UiAnimationClip[]; anchor: { x: number; y: number; }; }; export type UiSnapshot = { state: string; current_animation: string; x: number; y: number; scale: number; active_sprite_pack: string; visible: boolean; always_on_top: boolean; }; type AnimationMap = Map; const KEY_R = 0xff; const KEY_G = 0x00; const KEY_B = 0xff; const FALLBACK_MIN_CONNECTED_RATIO = 0.005; const CONNECTED_HUE_MIN = 270; const CONNECTED_HUE_MAX = 350; const CONNECTED_SAT_MIN = 0.25; const CONNECTED_VAL_MIN = 0.08; const FALLBACK_HUE_MIN = 255; const FALLBACK_HUE_MAX = 355; const FALLBACK_SAT_MIN = 0.15; const FALLBACK_VAL_MIN = 0.04; const STRONG_MAGENTA_RB_MIN = 72; const STRONG_MAGENTA_DOMINANCE = 24; const HALO_HUE_MIN = 245; const HALO_HUE_MAX = 355; const HALO_SAT_MIN = 0.15; const HALO_VAL_MIN = 0.04; const RENDER_FIT_PADDING = 16; const MIN_RENDER_SCALE = 0.01; const ANIMATION_SLOWDOWN_FACTOR_MIN = 1; const ANIMATION_SLOWDOWN_FACTOR_MAX = 200; const ANIMATION_SLOWDOWN_FACTOR_DEFAULT = 3; export class PixiPetRenderer { private app: Application; private sprite: Sprite; private pack: UiSpritePack; private animationMap: AnimationMap; private currentClip: UiAnimationClip; private frameCursor = 0; private frameElapsedMs = 0; private baseTexture: BaseTexture; private animationSlowdownFactor = ANIMATION_SLOWDOWN_FACTOR_DEFAULT; private disposed = false; private constructor( app: Application, sprite: Sprite, pack: UiSpritePack, baseTexture: BaseTexture ) { this.app = app; this.sprite = sprite; this.pack = pack; this.baseTexture = baseTexture; this.animationMap = new Map(pack.animations.map((clip) => [clip.name, clip])); this.currentClip = this.resolveClip("idle"); this.applyFrameTexture(this.currentClip.frames[0] ?? 0); } static async create( container: HTMLElement, pack: UiSpritePack, snapshot: UiSnapshot ): Promise { const app = new Application({ backgroundAlpha: 0, antialias: true, resizeTo: container }); const baseTexture = await PixiPetRenderer.loadBaseTexture(pack.atlas_data_url); if (baseTexture.width <= 0 || baseTexture.height <= 0) { throw new Error("Atlas image loaded with invalid dimensions."); } const sprite = new Sprite(); sprite.anchor.set(pack.anchor.x, pack.anchor.y); app.stage.addChild(sprite); container.replaceChildren(app.view as HTMLCanvasElement); const renderer = new PixiPetRenderer(app, sprite, pack, baseTexture); renderer.layoutSprite(); renderer.applySnapshot(snapshot); renderer.startTicker(); return renderer; } private static loadBaseTexture(dataUrl: string): Promise { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => { const canvas = document.createElement("canvas"); canvas.width = image.width; canvas.height = image.height; const ctx = canvas.getContext("2d"); if (ctx === null) { reject(new Error("Failed to create canvas context for chroma-key conversion.")); return; } ctx.drawImage(image, 0, 0); const frame = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = frame.data; const width = canvas.width; const height = canvas.height; const pixelCount = width * height; const isKeyLike = new Uint8Array(pixelCount); const removedBg = new Uint8Array(pixelCount); const queue = new Int32Array(pixelCount); let head = 0; let tail = 0; const indexFor = (x: number, y: number): number => y * width + x; const channelOffset = (index: number): number => index * 4; const enqueueIfKeyLike = (x: number, y: number): void => { const idx = indexFor(x, y); if (isKeyLike[idx] === 1 && removedBg[idx] === 0) { removedBg[idx] = 1; queue[tail] = idx; tail += 1; } }; for (let idx = 0; idx < pixelCount; idx += 1) { const offset = channelOffset(idx); const [h, s, v] = PixiPetRenderer.rgbToHsv( data[offset], data[offset + 1], data[offset + 2] ); if ( PixiPetRenderer.isHueInRange(h, CONNECTED_HUE_MIN, CONNECTED_HUE_MAX) && s >= CONNECTED_SAT_MIN && v >= CONNECTED_VAL_MIN ) { isKeyLike[idx] = 1; continue; } if ( PixiPetRenderer.isStrongMagentaFamily( data[offset], data[offset + 1], data[offset + 2] ) ) { isKeyLike[idx] = 1; } } for (let x = 0; x < width; x += 1) { enqueueIfKeyLike(x, 0); enqueueIfKeyLike(x, height - 1); } for (let y = 1; y < height - 1; y += 1) { enqueueIfKeyLike(0, y); enqueueIfKeyLike(width - 1, y); } while (head < tail) { const idx = queue[head]; head += 1; const x = idx % width; const y = Math.floor(idx / width); if (x > 0) { enqueueIfKeyLike(x - 1, y); } if (x + 1 < width) { enqueueIfKeyLike(x + 1, y); } if (y > 0) { enqueueIfKeyLike(x, y - 1); } if (y + 1 < height) { enqueueIfKeyLike(x, y + 1); } } const connectedRemovedCount = tail; for (let idx = 0; idx < pixelCount; idx += 1) { if (removedBg[idx] !== 1) { continue; } const offset = channelOffset(idx); data[offset + 3] = 0; } const needsFallback = connectedRemovedCount / Math.max(pixelCount, 1) < FALLBACK_MIN_CONNECTED_RATIO; if (needsFallback) { for (let idx = 0; idx < pixelCount; idx += 1) { const offset = channelOffset(idx); const [h, s, v] = PixiPetRenderer.rgbToHsv( data[offset], data[offset + 1], data[offset + 2] ); const maxDistanceFromHardKey = PixiPetRenderer.maxColorDistance( data[offset], data[offset + 1], data[offset + 2], KEY_R, KEY_G, KEY_B ); if ( (PixiPetRenderer.isHueInRange(h, FALLBACK_HUE_MIN, FALLBACK_HUE_MAX) && s >= FALLBACK_SAT_MIN && v >= FALLBACK_VAL_MIN) || PixiPetRenderer.isStrongMagentaFamily( data[offset], data[offset + 1], data[offset + 2] ) || maxDistanceFromHardKey <= 96 ) { data[offset + 3] = 0; } } } // Deterministic last pass: remove any border-connected magenta-family background. head = 0; tail = 0; removedBg.fill(0); const enqueueIfMagentaBorder = (x: number, y: number): void => { const idx = indexFor(x, y); if (removedBg[idx] === 1) { return; } const offset = channelOffset(idx); if (data[offset + 3] === 0) { return; } if ( !PixiPetRenderer.isStrongMagentaFamily( data[offset], data[offset + 1], data[offset + 2] ) ) { return; } removedBg[idx] = 1; queue[tail] = idx; tail += 1; }; for (let x = 0; x < width; x += 1) { enqueueIfMagentaBorder(x, 0); enqueueIfMagentaBorder(x, height - 1); } for (let y = 1; y < height - 1; y += 1) { enqueueIfMagentaBorder(0, y); enqueueIfMagentaBorder(width - 1, y); } while (head < tail) { const idx = queue[head]; head += 1; const x = idx % width; const y = Math.floor(idx / width); if (x > 0) { enqueueIfMagentaBorder(x - 1, y); } if (x + 1 < width) { enqueueIfMagentaBorder(x + 1, y); } if (y > 0) { enqueueIfMagentaBorder(x, y - 1); } if (y + 1 < height) { enqueueIfMagentaBorder(x, y + 1); } } for (let idx = 0; idx < pixelCount; idx += 1) { if (removedBg[idx] !== 1) { continue; } const offset = channelOffset(idx); data[offset + 3] = 0; } for (let y = 0; y < height; y += 1) { for (let x = 0; x < width; x += 1) { const idx = indexFor(x, y); if (data[channelOffset(idx) + 3] === 0) { continue; } let touchesBackground = false; if (x > 0 && data[channelOffset(indexFor(x - 1, y)) + 3] === 0) { touchesBackground = true; } else if (x + 1 < width && data[channelOffset(indexFor(x + 1, y)) + 3] === 0) { touchesBackground = true; } else if (y > 0 && data[channelOffset(indexFor(x, y - 1)) + 3] === 0) { touchesBackground = true; } else if (y + 1 < height && data[channelOffset(indexFor(x, y + 1)) + 3] === 0) { touchesBackground = true; } if (!touchesBackground) { continue; } const offset = channelOffset(idx); const [h, s, v] = PixiPetRenderer.rgbToHsv( data[offset], data[offset + 1], data[offset + 2] ); if ( !PixiPetRenderer.isHueInRange(h, HALO_HUE_MIN, HALO_HUE_MAX) || s < HALO_SAT_MIN || v < HALO_VAL_MIN ) { continue; } data[offset] = Math.round(data[offset] * 0.72); data[offset + 2] = Math.round(data[offset + 2] * 0.72); data[offset + 3] = Math.round(data[offset + 3] * 0.86); } } ctx.putImageData(frame, 0, 0); resolve(BaseTexture.from(canvas)); }; image.onerror = () => { reject(new Error("Failed to load atlas image data URL.")); }; image.src = dataUrl; }); } private static maxColorDistance( r: number, g: number, b: number, keyR: number, keyG: number, keyB: number ): number { const dr = Math.abs(r - keyR); const dg = Math.abs(g - keyG); const db = Math.abs(b - keyB); return Math.max(dr, dg, db); } private static rgbToHsv(r: number, g: number, b: number): [number, number, number] { const rf = r / 255; const gf = g / 255; const bf = b / 255; const max = Math.max(rf, gf, bf); const min = Math.min(rf, gf, bf); const delta = max - min; let hue = 0; if (delta > 0) { if (max === rf) { hue = 60 * (((gf - bf) / delta) % 6); } else if (max === gf) { hue = 60 * ((bf - rf) / delta + 2); } else { hue = 60 * ((rf - gf) / delta + 4); } } if (hue < 0) { hue += 360; } const saturation = max === 0 ? 0 : delta / max; const value = max; return [hue, saturation, value]; } private static isHueInRange(hue: number, min: number, max: number): boolean { if (min <= max) { return hue >= min && hue <= max; } return hue >= min || hue <= max; } private static isStrongMagentaFamily(r: number, g: number, b: number): boolean { const minRb = Math.min(r, b); return ( r >= STRONG_MAGENTA_RB_MIN && b >= STRONG_MAGENTA_RB_MIN && g + STRONG_MAGENTA_DOMINANCE <= minRb ); } dispose(): void { if (this.disposed) { return; } this.disposed = true; this.app.destroy(true, { children: true, texture: false, baseTexture: false }); } applySnapshot(snapshot: UiSnapshot): void { if (this.disposed) { return; } const nextClip = this.resolveClip(snapshot.current_animation); if (nextClip.name !== this.currentClip.name) { this.currentClip = nextClip; this.frameCursor = 0; this.frameElapsedMs = 0; this.applyFrameTexture(this.currentClip.frames[0] ?? 0); } this.layoutSprite(); } setAnimationSlowdownFactor(factor: number): void { if (!Number.isFinite(factor)) { return; } const rounded = Math.round(factor); this.animationSlowdownFactor = Math.min( Math.max(rounded, ANIMATION_SLOWDOWN_FACTOR_MIN), ANIMATION_SLOWDOWN_FACTOR_MAX ); } private startTicker(): void { this.app.ticker.add((ticker) => { if (this.disposed) { return; } this.layoutSprite(); const frameMs = (1000 / Math.max(this.currentClip.fps, 1)) * this.animationSlowdownFactor; this.frameElapsedMs += ticker.deltaMS; if (this.frameElapsedMs < frameMs) { return; } this.frameElapsedMs -= frameMs; const frames = this.currentClip.frames; if (frames.length === 0) { return; } if (this.frameCursor >= frames.length) { this.frameCursor = this.currentClip.one_shot ? frames.length - 1 : 0; } const frame = frames[this.frameCursor] ?? 0; this.applyFrameTexture(frame); this.frameCursor += 1; }); } private layoutSprite(): void { const availableWidth = Math.max(this.app.renderer.width - RENDER_FIT_PADDING, 1); const availableHeight = Math.max(this.app.renderer.height - RENDER_FIT_PADDING, 1); const fitScaleX = availableWidth / Math.max(this.pack.frame_width, 1); const fitScaleY = availableHeight / Math.max(this.pack.frame_height, 1); const fitScale = Math.max(Math.min(fitScaleX, fitScaleY), MIN_RENDER_SCALE); this.sprite.scale.set(fitScale); this.sprite.position.set(this.app.renderer.width / 2, this.app.renderer.height); } private resolveClip(name: string): UiAnimationClip { return ( this.animationMap.get(name) ?? this.animationMap.get("idle") ?? this.pack.animations[0] ?? { name: "idle", fps: 1, frames: [0], one_shot: false } ); } private applyFrameTexture(frameIndex: number): void { const atlasWidth = this.baseTexture.width; const atlasHeight = this.baseTexture.height; const columns = Math.max(Math.floor(atlasWidth / this.pack.frame_width), 1); const rows = Math.max(Math.floor(atlasHeight / this.pack.frame_height), 1); const totalFrames = Math.max(columns * rows, 1); const safeIndex = Math.max(0, Math.min(frameIndex, totalFrames - 1)); const x = (safeIndex % columns) * this.pack.frame_width; const y = Math.floor(safeIndex / columns) * this.pack.frame_height; const rect = new Rectangle(x, y, this.pack.frame_width, this.pack.frame_height); this.sprite.texture = new Texture(this.baseTexture, rect); } }