Add: tauri frontend as bevy alternative

This commit is contained in:
DaZuo0122
2026-02-13 09:57:08 +08:00
parent b0f462f63e
commit 3c3ca342c9
33 changed files with 11798 additions and 106 deletions

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sprimo-tauri</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2178
frontend/tauri-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"name": "sprimo-tauri-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "npx vite",
"build": "npx vite build",
"preview": "npx vite preview"
},
"dependencies": {
"@pixi/app": "^7.4.3",
"@pixi/core": "^7.4.3",
"@pixi/sprite": "^7.4.3",
"@tauri-apps/api": "^2.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.2"
}
}

View 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>
);

View 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);
}
}

View 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;
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": false,
"strict": true,
"jsx": "react-jsx",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 1420,
strictPort: true
}
});