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; }; type AnimationMap = Map; 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 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 }); container.replaceChildren(app.view as HTMLCanvasElement); 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); 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 keyR = 0xff; const keyG = 0x00; const keyB = 0xff; const tolerance = 28; for (let i = 0; i < data.length; i += 4) { const dr = Math.abs(data[i] - keyR); const dg = Math.abs(data[i + 1] - keyG); const db = Math.abs(data[i + 2] - keyB); if (dr <= tolerance && dg <= tolerance && db <= tolerance) { data[i + 3] = 0; } } ctx.putImageData(frame, 0, 0); resolve(BaseTexture.from(canvas)); }; image.onerror = () => { reject(new Error("Failed to load atlas image data URL.")); }; image.src = dataUrl; }); } dispose(): void { this.app.ticker.stop(); this.app.ticker.destroy(); this.sprite.destroy({ children: true, texture: false, baseTexture: false }); this.app.destroy(true, { children: true, texture: false, baseTexture: false }); } applySnapshot(snapshot: UiSnapshot): void { 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.sprite.scale.set(snapshot.scale); this.layoutSprite(); } private startTicker(): void { this.app.ticker.add((ticker) => { this.layoutSprite(); const frameMs = 1000 / Math.max(this.currentClip.fps, 1); 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 { 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); } }