511 lines
15 KiB
TypeScript
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);
|
|
}
|
|
}
|