Add: tauri frontend as bevy alternative
This commit is contained in:
93
frontend/tauri-ui/src/main.tsx
Normal file
93
frontend/tauri-ui/src/main.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { PixiPetRenderer, type UiSpritePack, type UiSnapshot } from "./renderer/pixi_pet";
|
||||
import "./styles.css";
|
||||
|
||||
const UI_BUILD_MARKER = "issue2-fix3";
|
||||
|
||||
function App(): JSX.Element {
|
||||
const [snapshot, setSnapshot] = React.useState<UiSnapshot | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const rendererRef = React.useRef<PixiPetRenderer | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let unlisten: null | (() => void) = null;
|
||||
let mounted = true;
|
||||
Promise.all([
|
||||
invoke<UiSpritePack>("load_active_sprite_pack"),
|
||||
invoke<UiSnapshot>("current_state")
|
||||
])
|
||||
.then(async ([pack, initialSnapshot]) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setSnapshot(initialSnapshot);
|
||||
if (hostRef.current !== null) {
|
||||
rendererRef.current = await PixiPetRenderer.create(
|
||||
hostRef.current,
|
||||
pack,
|
||||
initialSnapshot
|
||||
);
|
||||
}
|
||||
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
const value = event.payload;
|
||||
setSnapshot(value);
|
||||
rendererRef.current?.applySnapshot(value);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
if (mounted) {
|
||||
setError(String(err));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (unlisten !== null) {
|
||||
unlisten();
|
||||
}
|
||||
rendererRef.current?.dispose();
|
||||
rendererRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="app">
|
||||
<div className="canvas-host" ref={hostRef} />
|
||||
<section className="debug-panel">
|
||||
<h1>sprimo-tauri</h1>
|
||||
<p>ui build: {UI_BUILD_MARKER}</p>
|
||||
{error !== null ? <p className="error">{error}</p> : null}
|
||||
{snapshot === null ? (
|
||||
<p>Loading snapshot...</p>
|
||||
) : (
|
||||
<dl>
|
||||
<dt>state</dt>
|
||||
<dd>{snapshot.state}</dd>
|
||||
<dt>animation</dt>
|
||||
<dd>{snapshot.current_animation}</dd>
|
||||
<dt>pack</dt>
|
||||
<dd>{snapshot.active_sprite_pack}</dd>
|
||||
<dt>position</dt>
|
||||
<dd>
|
||||
{snapshot.x}, {snapshot.y}
|
||||
</dd>
|
||||
<dt>scale</dt>
|
||||
<dd>{snapshot.scale}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
54
frontend/tauri-ui/src/styles.css
Normal file
54
frontend/tauri-ui/src/styles.css
Normal file
@@ -0,0 +1,54 @@
|
||||
:root {
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas-host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.debug-panel {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
min-width: 220px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #fecaca;
|
||||
}
|
||||
Reference in New Issue
Block a user