Files
Sprimo/frontend/tauri-ui/src/renderer/pixi_pet.ts
2026-02-13 17:25:28 +08:00

204 lines
5.9 KiB
TypeScript

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<string, UiAnimationClip>;
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<PixiPetRenderer> {
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<BaseTexture> {
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);
}
}