Add: tauri frontend as bevy alternative
This commit is contained in:
197
frontend/tauri-ui/src/renderer/pixi_pet.ts
Normal file
197
frontend/tauri-ui/src/renderer/pixi_pet.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
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);
|
||||
sprite.position.set(app.renderer.width / 2, app.renderer.height);
|
||||
app.stage.addChild(sprite);
|
||||
|
||||
const renderer = new PixiPetRenderer(app, sprite, pack, baseTexture);
|
||||
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);
|
||||
}
|
||||
|
||||
private startTicker(): void {
|
||||
this.app.ticker.add((ticker) => {
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user