From 8f6573cff0fa7262a488be8afcb7cb585ba62526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 11 Aug 2026 19:00:30 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=BE=E8=AE=A1UI=E5=B8=83=E5=B1=80=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E6=95=B0=E6=8D=AE=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参考godot和ugui的api拆分布局, 资源, 样式 实现ts type binding --- .../src-tauri/Cargo.lock | 174 ++++++++++++++ .../src-tauri/Cargo.toml | 3 + .../src-tauri/src/main.rs | 1 + .../src-tauri/src/ui_editor/bindings.rs | 68 ++++++ .../src/ui_editor/component/common/error.rs | 27 +++ .../src/ui_editor/component/common/mod.rs | 1 + .../src/ui_editor/component/image.rs | 213 ++++++++++++++++++ .../src-tauri/src/ui_editor/component/mod.rs | 8 + .../src-tauri/src/ui_editor/component/text.rs | 112 +++++++++ .../src-tauri/src/ui_editor/import/mod.rs | 2 + .../src/ui_editor/import/ui_design_image.rs | 10 + .../src/ui_editor/layout/dimension.rs | 37 +++ .../src-tauri/src/ui_editor/layout/mod.rs | 3 + .../src-tauri/src/ui_editor/layout/node.rs | 10 + .../src/ui_editor/layout/transform.rs | 213 ++++++++++++++++++ .../src-tauri/src/ui_editor/mod.rs | 7 + .../src/ui_editor/resource/asset_id.rs | 31 +++ .../src-tauri/src/ui_editor/resource/mod.rs | 2 + .../src/ui_editor/resource/sprite.rs | 125 ++++++++++ .../src/features/ui-editor/types.ts | 47 ++++ 20 files changed, 1094 insertions(+) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/bindings.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/common/error.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/common/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/image.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/text.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/import/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/import/ui_design_image.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/dimension.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/transform.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/asset_id.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/sprite.rs create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types.ts diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index bf5707012..b79d2596b 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -75,6 +75,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -696,6 +705,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const_fn" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413d67b29ef1021b4d60f4aa1e925ca031751e213832b4b1d588fae623c05c60" + [[package]] name = "cookie" version = "0.18.1" @@ -1639,6 +1654,7 @@ dependencies = [ "image", "jsonschema", "libc", + "nalgebra", "oxc_allocator", "oxc_ast", "oxc_ast_visit", @@ -1664,6 +1680,8 @@ dependencies = [ "tauri-plugin-opener", "tempfile", "tokio", + "ts-rs", + "typed_floats", "unicode-normalization", "url", "uuid", @@ -1764,6 +1782,30 @@ dependencies = [ "winapi", ] +[[package]] +name = "glam" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" + +[[package]] +name = "glam" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7" + +[[package]] +name = "glam" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" + +[[package]] +name = "glam" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360bd2cd76e0cd9032d42cf2922155cecea2685b0cfa4630c3246df030bcfd6" + [[package]] name = "glib" version = "0.18.5" @@ -2605,6 +2647,16 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "memchr" version = "2.8.2" @@ -2694,6 +2746,37 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nalgebra" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc43a60c217b0c6ff46e47f26911015ad8d2e5a8be1af668c67e370d99a4346" +dependencies = [ + "approx", + "glam 0.30.10", + "glam 0.31.1", + "glam 0.32.1", + "glam 0.33.3", + "matrixmultiply", + "nalgebra-macros", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "nalgebra-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973e7178a678cfd059ccec50887658d482ce16b0aa9da3888ddeab5cd5eb4889" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -4004,6 +4087,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4327,6 +4416,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safe_arch" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a52ec151f024d703f9fd65abb7cbe81e7cdb39f18917a3a37e3014470dc7c59" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4723,6 +4821,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simba" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a7200d82ff1c7b4235efd12f11e2537a402c91cf83a5cb97ce80eef787fde7" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "wide", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -5390,6 +5500,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -5816,6 +5935,28 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "thiserror 2.0.18", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "termcolor", +] + [[package]] name = "tungstenite" version = "0.28.0" @@ -5833,6 +5974,29 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typed_floats" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436de2fdc42c0f07890f4b51dcc90a468b0b12280f49caff1cf57ce2e646817c" +dependencies = [ + "const_fn", + "rustversion", + "typed_floats_macros", +] + +[[package]] +name = "typed_floats_macros" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da27e33675dd7bfc62c7ead8c36779f961815ababe717191d67b75c2356edc4f" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.118", +] + [[package]] name = "typeid" version = "1.0.3" @@ -6384,6 +6548,16 @@ dependencies = [ "libc", ] +[[package]] +name = "wide" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de2aaf408e58689c2096682331b1f42bb2d9f2ed6b11560407d023cd0a6c634e" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 6fc26eb61..53c7af709 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -15,6 +15,9 @@ shared-contracts = { path = "../../../server-rs/crates/shared-contracts", defaul tauri-build = { version = "2.6.2", features = [] } [dependencies] +ts-rs = "12.0.1" +typed_floats = "1.0.7" +nalgebra = "0.35.0" agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" } base64 = "0.22" chromiumoxide = "0.9.1" diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 2d533e865..ec376e62e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2482,3 +2482,4 @@ mod diagnostic_log_tests { #[cfg(test)] mod tests; +pub mod ui_editor; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/bindings.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/bindings.rs new file mode 100644 index 000000000..0c0f2209d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/bindings.rs @@ -0,0 +1,68 @@ +use std::{fs, io, path::PathBuf}; + +use ts_rs::{Config, TS}; + +use super::{ + component::{ + image::{ + FillMethod, HorizontalFillOrigin, ImageComponent, ImageType, Radial180Origin, + Radial360Origin, Radial90Origin, VerticalFillOrigin, + }, + text::{ + BestFitFontSize, FontSizing, FontStyle, HorizontalTextOverflow, TextAlignment, + TextComponent, VerticalTextOverflow, + }, + Component, + }, + layout::{dimension::UIRect, node::Node, transform::Transform}, + resource::{ + asset_id::{FontAssetId, SpriteAssetId}, + sprite::{SpriteAsset, SpriteBorder}, + }, +}; + +const GENERATED_HEADER: &str = + "// This file was generated by ts-rs. Do not edit this file manually."; + +fn typescript_bindings() -> String { + let config = Config::default(); + let declarations = [ + FontAssetId::decl(&config), + SpriteAssetId::decl(&config), + SpriteBorder::decl(&config), + SpriteAsset::decl(&config), + UIRect::decl(&config), + Transform::decl(&config), + HorizontalFillOrigin::decl(&config), + VerticalFillOrigin::decl(&config), + Radial90Origin::decl(&config), + Radial180Origin::decl(&config), + Radial360Origin::decl(&config), + FillMethod::decl(&config), + ImageType::decl(&config), + ImageComponent::decl(&config), + FontStyle::decl(&config), + TextAlignment::decl(&config), + HorizontalTextOverflow::decl(&config), + VerticalTextOverflow::decl(&config), + BestFitFontSize::decl(&config), + FontSizing::decl(&config), + TextComponent::decl(&config), + Component::decl(&config), + Node::decl(&config), + ]; + + format!( + "{GENERATED_HEADER}\n\nexport {}\n", + declarations.join("\n\nexport ") + ) +} + +fn bindings_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../src/features/ui-editor/types.ts") +} + +#[test] +fn export_typescript_bindings() -> io::Result<()> { + fs::write(bindings_path(), typescript_bindings()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/common/error.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/common/error.rs new file mode 100644 index 000000000..28091e353 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/common/error.rs @@ -0,0 +1,27 @@ +use std::{error::Error, fmt}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ComponentValueError { + InvalidAssetId, + NonFinite, + MustBePositive, + MustBeNonNegative, + InvalidRange, + SpriteBorderExceedsSprite, +} + +impl fmt::Display for ComponentValueError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::InvalidAssetId => "资源 ID 不能为空或包含首尾空白", + Self::NonFinite => "数值必须是有限数", + Self::MustBePositive => "数值必须大于零", + Self::MustBeNonNegative => "数值不能小于零", + Self::InvalidRange => "数值范围无效", + Self::SpriteBorderExceedsSprite => "九宫格边距不能超过 Sprite 尺寸", + }; + formatter.write_str(message) + } +} + +impl Error for ComponentValueError {} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/common/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/common/mod.rs new file mode 100644 index 000000000..a91e73517 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/common/mod.rs @@ -0,0 +1 @@ +pub mod error; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/image.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/image.rs new file mode 100644 index 000000000..66c9fffb2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/image.rs @@ -0,0 +1,213 @@ +use std::{error::Error, fmt}; + +use crate::ui_editor::component::common::error::ComponentValueError; +use crate::ui_editor::layout::transform::Transform; +use crate::ui_editor::resource::sprite::SpriteAsset; +use nalgebra::Vector2; +use ts_rs::TS; +use typed_floats::tf32::StrictlyPositiveFinite; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)] +pub enum HorizontalFillOrigin { + Left, + Right, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)] +pub enum VerticalFillOrigin { + Bottom, + Top, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)] +pub enum Radial90Origin { + BottomLeft, + TopLeft, + TopRight, + BottomRight, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)] +pub enum Radial180Origin { + Bottom, + Left, + Top, + Right, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)] +pub enum Radial360Origin { + Bottom, + Right, + Top, + Left, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)] +pub enum FillMethod { + Horizontal(HorizontalFillOrigin), + Vertical(VerticalFillOrigin), + Radial90 { + origin: Radial90Origin, + clockwise: bool, + }, + Radial180 { + origin: Radial180Origin, + clockwise: bool, + }, + Radial360 { + origin: Radial360Origin, + clockwise: bool, + }, +} + +fn validate_fill_amount(amount: f32) -> Result<(), ComponentValueError> { + if !amount.is_finite() { + return Err(ComponentValueError::NonFinite); + } + if !(0.0..=1.0).contains(&amount) { + return Err(ComponentValueError::InvalidRange); + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, TS)] +pub enum ImageType { + Simple { + preserve_aspect: bool, + }, + Sliced { + fill_center: bool, + #[ts(as = "f32")] + pixels_per_unit_multiplier: StrictlyPositiveFinite, + }, + Tiled { + fill_center: bool, + #[ts(as = "f32")] + pixels_per_unit_multiplier: StrictlyPositiveFinite, + }, + Filled { + preserve_aspect: bool, + method: FillMethod, + amount: f32, + }, +} + +impl ImageType { + pub const fn sliced( + fill_center: bool, + pixels_per_unit_multiplier: StrictlyPositiveFinite, + ) -> Self { + Self::Sliced { + fill_center, + pixels_per_unit_multiplier, + } + } + + pub const fn tiled( + fill_center: bool, + pixels_per_unit_multiplier: StrictlyPositiveFinite, + ) -> Self { + Self::Tiled { + fill_center, + pixels_per_unit_multiplier, + } + } + + pub fn filled( + preserve_aspect: bool, + method: FillMethod, + amount: f32, + ) -> Result { + validate_fill_amount(amount)?; + Ok(Self::Filled { + preserve_aspect, + method, + amount, + }) + } + + pub fn supports_native_size(self) -> bool { + matches!(self, Self::Simple { .. } | Self::Filled { .. }) + } + + pub fn uses_sprite_border(self) -> bool { + matches!(self, Self::Sliced { .. } | Self::Tiled { .. }) + } + + pub fn tiles_center(self) -> bool { + matches!( + self, + Self::Tiled { + fill_center: true, + .. + } + ) + } +} + +impl Default for ImageType { + fn default() -> Self { + Self::Simple { + preserve_aspect: false, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SetNativeSizeError { + MissingSprite, + UnsupportedImageType, +} + +impl fmt::Display for SetNativeSizeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::MissingSprite => "Image 没有 Target Graphic Sprite", + Self::UnsupportedImageType => "只有 Simple 或 Filled Image 支持 Set Native Size", + }; + formatter.write_str(message) + } +} + +impl Error for SetNativeSizeError {} + +/// Image 渲染组件;布局由同一 Entity 上独立的 `Control` 提供。 +#[derive(Clone, Debug, PartialEq, TS)] +pub struct ImageComponent { + pub target_graphic: Option, + pub image_type: ImageType, +} + +impl ImageComponent { + pub fn new() -> Self { + Self { + target_graphic: None, + image_type: ImageType::default(), + } + } + + /// 跨组件把布局重置为 Sprite 的原始尺寸。 + pub fn set_native_size( + &self, + control: &mut Transform, + ) -> Result, SetNativeSizeError> { + if !self.image_type.supports_native_size() { + return Err(SetNativeSizeError::UnsupportedImageType); + } + let native_size = self + .target_graphic + .as_ref() + .ok_or(SetNativeSizeError::MissingSprite)? + .native_size(); + control.anchor_max = control.anchor_min; + control.offset_max = control.offset_min + native_size; + Ok(native_size) + } +} + +impl Default for ImageComponent { + fn default() -> Self { + Self::new() + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/mod.rs new file mode 100644 index 000000000..abc4cdd28 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/mod.rs @@ -0,0 +1,8 @@ +pub mod common; +pub mod image; +pub mod text; +#[derive(ts_rs::TS)] +pub enum Component { + Image(image::ImageComponent), + Text(text::TextComponent), +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/text.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/text.rs new file mode 100644 index 000000000..abf5ccae5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/text.rs @@ -0,0 +1,112 @@ +use crate::ui_editor::component::common::error::ComponentValueError; +use crate::ui_editor::resource::asset_id::FontAssetId; +use crate::ui_editor::resource::sprite::{Color, WHITE}; +use std::num::NonZeroU32; +use ts_rs::TS; +use typed_floats::tf32::StrictlyPositiveFinite; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)] +pub enum FontStyle { + Normal, + Bold, + Italic, + BoldItalic, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)] +pub enum TextAlignment { + UpperLeft, + UpperCenter, + UpperRight, + MiddleLeft, + MiddleCenter, + MiddleRight, + LowerLeft, + LowerCenter, + LowerRight, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)] +pub enum HorizontalTextOverflow { + Wrap, + Overflow, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)] +pub enum VerticalTextOverflow { + Truncate, + Overflow, +} + +#[derive(Clone, Copy, Debug, PartialEq, TS)] +pub struct BestFitFontSize { + min: NonZeroU32, + max: NonZeroU32, +} + +impl BestFitFontSize { + pub fn new(min: NonZeroU32, max: NonZeroU32) -> Result { + if min > max { + return Err(ComponentValueError::InvalidRange); + } + Ok(Self { min, max }) + } + + pub fn min(self) -> NonZeroU32 { + self.min + } + + pub fn max(self) -> NonZeroU32 { + self.max + } +} + +#[derive(Clone, Copy, Debug, PartialEq, TS)] +pub enum FontSizing { + Fixed(NonZeroU32), + BestFit(BestFitFontSize), +} + +impl Default for FontSizing { + fn default() -> Self { + Self::Fixed(NonZeroU32::new(14).expect("14 is non-zero")) + } +} + +/// Text / Label 渲染组件;布局由同一 Entity 上独立的 `Control` 提供。 +#[derive(Clone, Debug, PartialEq, TS)] +pub struct TextComponent { + pub content: String, + pub font: Option, + pub font_style: FontStyle, + pub font_sizing: FontSizing, + #[ts(as = "[u8; 4]")] + pub color: Color, + pub alignment: TextAlignment, + pub horizontal_overflow: HorizontalTextOverflow, + pub vertical_overflow: VerticalTextOverflow, + #[ts(as = "f32")] + pub line_spacing: StrictlyPositiveFinite, +} + +impl TextComponent { + pub fn new(content: impl Into) -> Self { + Self { + content: content.into(), + font: None, + font_style: FontStyle::Normal, + font_sizing: FontSizing::default(), + color: WHITE, + alignment: TextAlignment::UpperLeft, + horizontal_overflow: HorizontalTextOverflow::Wrap, + vertical_overflow: VerticalTextOverflow::Truncate, + line_spacing: StrictlyPositiveFinite::new(1.0).expect("1.0 is positive and finite"), + } + } +} + +impl Default for TextComponent { + fn default() -> Self { + Self::new(String::new()) + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/import/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/import/mod.rs new file mode 100644 index 000000000..1604b39b6 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/import/mod.rs @@ -0,0 +1,2 @@ +pub mod ui_design_image; + diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/import/ui_design_image.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/import/ui_design_image.rs new file mode 100644 index 000000000..11511d4ae --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/import/ui_design_image.rs @@ -0,0 +1,10 @@ +pub enum UIDesignImageRole{ + Page, + Section, + Modal, + Drawer, + Popover, + State, + Scrolled, + Detail, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/dimension.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/dimension.rs new file mode 100644 index 000000000..fd316e5f4 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/dimension.rs @@ -0,0 +1,37 @@ +use nalgebra::{Point2, Vector2}; +use ts_rs::TS; + +/// UI 布局在父级坐标系中的轴对齐矩形。 +/// +/// `min` 是矩形的最小角,`size` 是沿两个坐标轴的尺寸。这里不规定 Y 轴方向, +/// 因而既能用于 Y 轴向上的游戏坐标,也能用于 Y 轴向下的画布坐标。 +#[derive(Clone, Copy, Debug, PartialEq, TS)] +pub struct UIRect { + #[ts(as = "[f32; 2]")] + pub min: Point2, + #[ts(as = "[f32; 2]")] + pub size: Vector2, +} + +impl UIRect { + pub fn new(min: Point2, size: Vector2) -> Self { + Self { min, size } + } + + pub fn from_min_max(min: Point2, max: Point2) -> Self { + Self::new(min, max - min) + } + + pub fn max(&self) -> Point2 { + self.min + self.size + } + + pub fn center(&self) -> Point2 { + self.min + self.size * 0.5 + } + + /// 把矩形内部的归一化坐标转换为当前坐标系中的点。 + pub fn point_at(&self, normalized_position: Vector2) -> Point2 { + self.min + self.size.component_mul(&normalized_position) + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs new file mode 100644 index 000000000..aa492c350 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs @@ -0,0 +1,3 @@ +pub mod dimension; +pub mod node; +pub mod transform; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs new file mode 100644 index 000000000..94a423dc2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs @@ -0,0 +1,10 @@ +use crate::ui_editor::component::Component; +use crate::ui_editor::layout::transform::Transform; +use ts_rs::TS; + +#[derive(TS)] +pub struct Node { + transform: Transform, + components: Vec, + children: Vec, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/transform.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/transform.rs new file mode 100644 index 000000000..4621ea4fa --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/transform.rs @@ -0,0 +1,213 @@ +use crate::ui_editor::layout::dimension::UIRect; +use nalgebra::Vector2; +use ts_rs::TS; + +#[derive(Clone, Copy, Debug, PartialEq, TS)] +pub struct Transform { + #[ts(as = "[f32; 2]")] + pub anchor_min: Vector2, + #[ts(as = "[f32; 2]")] + pub anchor_max: Vector2, + #[ts(as = "[f32; 2]")] + pub offset_min: Vector2, + #[ts(as = "[f32; 2]")] + pub offset_max: Vector2, +} + +impl Transform { + pub fn new( + anchor_min: Vector2, + anchor_max: Vector2, + offset_min: Vector2, + offset_max: Vector2, + ) -> Self { + Self { + anchor_min, + anchor_max, + offset_min, + offset_max, + } + } + + /// 创建中心锚定、指定尺寸的矩形。 + pub fn centered(size: Vector2) -> Self { + let half_size = size * 0.5; + Self::new( + Vector2::new(0.5, 0.5), + Vector2::new(0.5, 0.5), + -half_size, + half_size, + ) + } + + /// 创建铺满父矩形的拉伸布局。 + pub fn stretch() -> Self { + Self::new( + Vector2::zeros(), + Vector2::repeat(1.0), + Vector2::zeros(), + Vector2::zeros(), + ) + } + + /// 锚点在父矩形中覆盖的尺寸。 + pub fn anchor_span_size(&self, parent: &UIRect) -> Vector2 { + parent + .size + .component_mul(&(self.anchor_max - self.anchor_min)) + } + + /// 在给定父矩形下得到最终尺寸。 + pub fn resolved_size(&self, parent: &UIRect) -> Vector2 { + self.anchor_span_size(parent) + self.offset_max - self.offset_min + } + + /// 在给定父矩形下解析出最终轴对齐矩形。 + pub fn resolve(&self, parent: &UIRect) -> UIRect { + let min = parent.point_at(self.anchor_min) + self.offset_min; + let max = parent.point_at(self.anchor_max) + self.offset_max; + UIRect::from_min_max(min, max) + } + + /// 调整 offsets,使解析结果匹配目标矩形,anchors 保持不变。 + pub fn set_resolved_rect(&mut self, parent: &UIRect, rect: UIRect) { + self.offset_min = rect.min - parent.point_at(self.anchor_min); + self.offset_max = rect.max() - parent.point_at(self.anchor_max); + } + + /// 修改 anchors,同时保持当前可见矩形不变。 + pub fn set_anchors_preserving_rect( + &mut self, + parent: &UIRect, + anchor_min: Vector2, + anchor_max: Vector2, + ) { + let rect = self.resolve(parent); + self.anchor_min = anchor_min; + self.anchor_max = anchor_max; + self.set_resolved_rect(parent, rect); + } + + /// 保持最小边不变,直接设置最终尺寸。 + pub fn set_size(&mut self, size: Vector2, parent: &UIRect) { + self.offset_max = self.offset_min + size - self.anchor_span_size(parent); + } +} + +impl Default for Transform { + fn default() -> Self { + Self::centered(Vector2::new(100.0, 100.0)) + } +} + +#[cfg(test)] +mod tests { + use crate::ui_editor::layout::dimension::UIRect; + use crate::ui_editor::layout::transform::Transform; + use nalgebra::{Point2, Vector2}; + + fn assert_vector_close(actual: Vector2, expected: Vector2) { + assert!( + (actual - expected).norm() <= 1.0e-4, + "actual={actual:?}, expected={expected:?}" + ); + } + + fn assert_point_close(actual: Point2, expected: Point2) { + assert_vector_close(actual - expected, Vector2::zeros()); + } + + fn assert_rect_close(actual: UIRect, expected: UIRect) { + assert_point_close(actual.min, expected.min); + assert_vector_close(actual.size, expected.size); + } + + #[test] + fn fixed_anchors_use_offsets_as_final_rect() { + let parent = UIRect::new(Point2::new(10.0, 20.0), Vector2::new(800.0, 600.0)); + let control = Transform::new( + Vector2::new(0.5, 0.5), + Vector2::new(0.5, 0.5), + Vector2::new(-20.0, -30.0), + Vector2::new(80.0, 10.0), + ); + + assert_rect_close( + control.resolve(&parent), + UIRect::new(Point2::new(390.0, 290.0), Vector2::new(100.0, 40.0)), + ); + } + + #[test] + fn split_anchors_add_parent_span_to_offsets() { + let parent = UIRect::new(Point2::new(10.0, 20.0), Vector2::new(800.0, 600.0)); + let control = Transform::new( + Vector2::new(0.1, 0.2), + Vector2::new(0.9, 0.8), + Vector2::new(10.0, 25.0), + Vector2::new(-10.0, -15.0), + ); + + assert_rect_close( + control.resolve(&parent), + UIRect::new(Point2::new(100.0, 165.0), Vector2::new(620.0, 320.0)), + ); + } + + #[test] + fn stretch_fills_a_parent_with_a_non_zero_origin() { + let parent = UIRect::new(Point2::new(-20.0, 35.0), Vector2::new(320.0, 180.0)); + + assert_rect_close(Transform::stretch().resolve(&parent), parent); + } + + #[test] + fn target_rect_can_be_converted_back_to_layout_properties() { + let parent = UIRect::new(Point2::new(100.0, -50.0), Vector2::new(900.0, 500.0)); + let expected = UIRect::new(Point2::new(240.0, 80.0), Vector2::new(360.0, 140.0)); + let mut control = Transform::new( + Vector2::new(0.2, 0.1), + Vector2::new(0.8, 0.9), + Vector2::zeros(), + Vector2::zeros(), + ); + + control.set_resolved_rect(&parent, expected); + + assert_rect_close(control.resolve(&parent), expected); + } + + #[test] + fn changing_anchors_can_preserve_the_visible_rect() { + let parent = UIRect::new(Point2::new(20.0, 30.0), Vector2::new(1280.0, 720.0)); + let mut control = Transform::new( + Vector2::new(0.0, 0.0), + Vector2::new(0.0, 0.0), + Vector2::new(120.0, 80.0), + Vector2::new(420.0, 240.0), + ); + let expected = control.resolve(&parent); + + control.set_anchors_preserving_rect( + &parent, + Vector2::new(0.2, 0.3), + Vector2::new(0.8, 0.7), + ); + assert_rect_close(control.resolve(&parent), expected); + } + + #[test] + fn set_size_accounts_for_parent_anchor_span() { + let parent = UIRect::new(Point2::origin(), Vector2::new(1000.0, 600.0)); + let mut control = Transform::new( + Vector2::new(0.25, 0.2), + Vector2::new(0.75, 0.8), + Vector2::zeros(), + Vector2::zeros(), + ); + + control.set_size(Vector2::new(640.0, 240.0), &parent); + + assert_vector_close(control.resolved_size(&parent), Vector2::new(640.0, 240.0)); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs new file mode 100644 index 000000000..5bb17c7ad --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs @@ -0,0 +1,7 @@ +pub mod component; +pub mod layout; +pub mod resource; + +#[cfg(test)] +mod bindings; +pub mod import; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/asset_id.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/asset_id.rs new file mode 100644 index 000000000..cc5ae75b9 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/asset_id.rs @@ -0,0 +1,31 @@ +use crate::ui_editor::component::common::error::ComponentValueError; + +macro_rules! define_asset_id { + ($name:ident) => { + #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, ts_rs::TS)] + pub struct $name(String); + + impl $name { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() || value.trim() != value { + return Err(ComponentValueError::InvalidAssetId); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl AsRef for $name { + fn as_ref(&self) -> &str { + self.as_str() + } + } + }; +} + +define_asset_id!(FontAssetId); +define_asset_id!(SpriteAssetId); diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/mod.rs new file mode 100644 index 000000000..1b05831a4 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/mod.rs @@ -0,0 +1,2 @@ +pub mod asset_id; +pub mod sprite; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/sprite.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/sprite.rs new file mode 100644 index 000000000..6220bf1bb --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/sprite.rs @@ -0,0 +1,125 @@ +use crate::ui_editor::component::common::error::ComponentValueError; +use crate::ui_editor::resource::asset_id::SpriteAssetId; +use image::Rgba; +use nalgebra::Vector2; +use ts_rs::TS; +use typed_floats::tf32::StrictlyPositiveFinite; + +pub type Color = Rgba; + +pub(in crate::ui_editor) const WHITE: Color = Rgba([255, 255, 255, 255]); + +#[derive(Clone, Copy, Debug, PartialEq, TS)] +pub struct SpriteBorder { + left: f32, + right: f32, + top: f32, + bottom: f32, +} + +impl SpriteBorder { + pub const NONE: Self = Self { + left: 0.0, + right: 0.0, + top: 0.0, + bottom: 0.0, + }; + + pub fn new(left: f32, right: f32, top: f32, bottom: f32) -> Result { + let values = [left, right, top, bottom]; + if values.iter().any(|value| !value.is_finite()) { + return Err(ComponentValueError::NonFinite); + } + if values.iter().any(|value| *value < 0.0) { + return Err(ComponentValueError::MustBeNonNegative); + } + Ok(Self { + left, + right, + top, + bottom, + }) + } + + pub fn left(self) -> f32 { + self.left + } + + pub fn right(self) -> f32 { + self.right + } + + pub fn top(self) -> f32 { + self.top + } + + pub fn bottom(self) -> f32 { + self.bottom + } + + pub fn has_border(self) -> bool { + self != Self::NONE + } +} + +impl Default for SpriteBorder { + fn default() -> Self { + Self::NONE + } +} + +/// UI Sprite 引用以及 `Set Native Size` / 9-slice 所需的导入元数据。 +#[derive(Clone, Debug, PartialEq, TS)] +pub struct SpriteAsset { + asset_id: SpriteAssetId, + #[ts(as = "[f32; 2]")] + pixel_size: Vector2, + #[ts(as = "f32")] + pixels_per_unit: StrictlyPositiveFinite, + border: SpriteBorder, +} + +impl SpriteAsset { + pub fn new( + asset_id: SpriteAssetId, + pixel_size: Vector2, + pixels_per_unit: StrictlyPositiveFinite, + border: SpriteBorder, + ) -> Result { + if !pixel_size.x.is_finite() || !pixel_size.y.is_finite() { + return Err(ComponentValueError::NonFinite); + } + if pixel_size.x <= 0.0 || pixel_size.y <= 0.0 { + return Err(ComponentValueError::MustBePositive); + } + if border.left + border.right > pixel_size.x || border.top + border.bottom > pixel_size.y { + return Err(ComponentValueError::SpriteBorderExceedsSprite); + } + Ok(Self { + asset_id, + pixel_size, + pixels_per_unit, + border, + }) + } + + pub fn asset_id(&self) -> &SpriteAssetId { + &self.asset_id + } + + pub fn pixel_size(&self) -> Vector2 { + self.pixel_size + } + + pub fn pixels_per_unit(&self) -> StrictlyPositiveFinite { + self.pixels_per_unit + } + + pub fn border(&self) -> SpriteBorder { + self.border + } + + pub fn native_size(&self) -> Vector2 { + self.pixel_size / self.pixels_per_unit.get() + } +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types.ts new file mode 100644 index 000000000..00b98581f --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types.ts @@ -0,0 +1,47 @@ +// This file was generated by ts-rs. Do not edit this file manually. + +export type FontAssetId = string; + +export type SpriteAssetId = string; + +export type SpriteBorder = { left: number, right: number, top: number, bottom: number, }; + +export type SpriteAsset = { asset_id: SpriteAssetId, pixel_size: [number, number], pixels_per_unit: number, border: SpriteBorder, }; + +export type UIRect = { min: [number, number], size: [number, number], }; + +export type Transform = { anchor_min: [number, number], anchor_max: [number, number], offset_min: [number, number], offset_max: [number, number], }; + +export type HorizontalFillOrigin = "Left" | "Right"; + +export type VerticalFillOrigin = "Bottom" | "Top"; + +export type Radial90Origin = "BottomLeft" | "TopLeft" | "TopRight" | "BottomRight"; + +export type Radial180Origin = "Bottom" | "Left" | "Top" | "Right"; + +export type Radial360Origin = "Bottom" | "Right" | "Top" | "Left"; + +export type FillMethod = { "Horizontal": HorizontalFillOrigin } | { "Vertical": VerticalFillOrigin } | { "Radial90": { origin: Radial90Origin, clockwise: boolean, } } | { "Radial180": { origin: Radial180Origin, clockwise: boolean, } } | { "Radial360": { origin: Radial360Origin, clockwise: boolean, } }; + +export type ImageType = { "Simple": { preserve_aspect: boolean, } } | { "Sliced": { fill_center: boolean, pixels_per_unit_multiplier: number, } } | { "Tiled": { fill_center: boolean, pixels_per_unit_multiplier: number, } } | { "Filled": { preserve_aspect: boolean, method: FillMethod, amount: number, } }; + +export type ImageComponent = { target_graphic: SpriteAsset | null, image_type: ImageType, }; + +export type FontStyle = "Normal" | "Bold" | "Italic" | "BoldItalic"; + +export type TextAlignment = "UpperLeft" | "UpperCenter" | "UpperRight" | "MiddleLeft" | "MiddleCenter" | "MiddleRight" | "LowerLeft" | "LowerCenter" | "LowerRight"; + +export type HorizontalTextOverflow = "Wrap" | "Overflow"; + +export type VerticalTextOverflow = "Truncate" | "Overflow"; + +export type BestFitFontSize = { min: number, max: number, }; + +export type FontSizing = { "Fixed": number } | { "BestFit": BestFitFontSize }; + +export type TextComponent = { content: string, font: FontAssetId | null, font_style: FontStyle, font_sizing: FontSizing, color: [number, number, number, number], alignment: TextAlignment, horizontal_overflow: HorizontalTextOverflow, vertical_overflow: VerticalTextOverflow, line_spacing: number, }; + +export type Component = { "Image": ImageComponent } | { "Text": TextComponent }; + +export type Node = { transform: Transform, components: Array, children: Array, };