Files
Sprimo/frontend/tauri-ui/src/renderer/pixi_pet.ts
2026-02-15 16:51:17 +08:00

511 lines
15 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;
visible: boolean;
always_on_top: boolean;
};
type AnimationMap = Map<string, UiAnimationClip>;
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<PixiPetRenderer> {
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<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 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);
}
}