diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index e19c3f15d..9833f1251 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1306,6 +1306,11 @@ assert.deepEqual( expectedBundledCodexResources, 'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set', ); +if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) { + throw new Error( + 'AI game creator shell Windows Tauri config must cache bundling tools in the project target directory', + ); +} if (tauriConfig.app?.withGlobalTauri !== true) { throw new Error( diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 291d47d42..f4ca27d53 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1715,6 +1715,7 @@ dependencies = [ "image", "jsonschema", "libc", + "maud", "nalgebra", "oxc_allocator", "oxc_ast", @@ -2735,6 +2736,28 @@ dependencies = [ "rawpointer", ] +[[package]] +name = "maud" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e" +dependencies = [ + "itoa", + "maud_macros", +] + +[[package]] +name = "maud_macros" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.118", +] + [[package]] name = "memchr" version = "2.8.2" @@ -3968,6 +3991,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "version_check", +] + [[package]] name = "psl-types" version = "2.0.11" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 944195556..7aa0e1cb4 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -59,6 +59,7 @@ unicode-normalization = "0.1" uuid = { version = "1", features = ["v4"] } zip = { version = "2", default-features = false, features = ["deflate"] } tauri-plugin-clipboard-manager = "2.3.2" +maud = "0.27.0" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md index 8448381b2..c7aa068b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md @@ -19,6 +19,10 @@ Implement the user's actual game request in the current project. Choose DOM, Can Call `agc_read_skill_resource` with `skillName="agc-web-game-development"` and `relativePath="references/game-quality-checklist.md"` when implementing a new game loop or a broad gameplay revision. +# Notes +* UI Editor's JSON formated is a special kind asset; Never edit it or read it directly. User can export to `ui/generated-.js` and want u to use it; + Read it instead, the doc embedded will instruct you how to use it. + ## Reporting Report the files changed, observable gameplay behavior, real validation performed, and any remaining limitation. Do not report a test or browser pass that did not run. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 6d38b0b09..281abf9c1 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.4", + "version": "2026-08-26.9", "skills": [ { "name": "agc-project-structure", @@ -61,7 +61,7 @@ "agents/openai.yaml", "references/game-quality-checklist.md" ], - "sha256": "b54646fc83eb48ffa270a726c63cdea5f05099d7990ec99024b00e255fb38d6c" + "sha256": "b197a242b75dc5e39739693e793acd25ac62dd2c72bc264305797936da47f356" }, { "name": "agc-browser-playtest", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index ba1dffecc..d739961aa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -1247,7 +1247,7 @@ mod tests { "agc_import_account_assets", "agc_create_or_derive_resource", "agc_remove_background", - "agc_browser_playtest" + "agc_browser_playtest", ] ); let serialized = specs.to_string(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 7044e53e8..79d824021 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -2405,13 +2405,17 @@ pub(super) fn try_acquire_game_creator_agent_runtime_task_lock_with_wait( root: &Path, agent_id: &str, ) -> Result, String> { - for attempt in 0..25 { + // Runtime state transitions can persist several audit projections while holding + // the lane lock. Keep the wait bounded, but allow a slow CI/disk-backed + // transition to finish before reporting a false busy error. + const MAX_ATTEMPTS: usize = 100; + for attempt in 0..MAX_ATTEMPTS { if let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, agent_id)? { return Ok(Some(runtime_lock)); } - if attempt < 24 { + if attempt + 1 < MAX_ATTEMPTS { std::thread::sleep(Duration::from_millis(10)); } } 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 bb7675f23..196d5b85a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -360,6 +360,15 @@ fn save_ui_design_state( ui_editor::persistence::save_ui_design_state_at(input) } +#[tauri::command] +fn generate_ui_design_code( + input: ui_editor::persistence::GenerateUiDesignCodeInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "file.write")?; + ui_editor::persistence::generate_ui_design_code_at(input) +} + #[tauri::command] fn ensure_ui_design_resource_for_prototype( input: ui_editor::resource_bridge::EnsureUiDesignResourceForPrototypeInput, @@ -2534,6 +2543,7 @@ fn main() { bind_components, load_ui_design_state, save_ui_design_state, + generate_ui_design_code, ensure_ui_design_resource_for_prototype, generate_platform_art_asset, open_canvas_project, diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index 6f13cd088..ab6c73245 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -1429,7 +1429,7 @@ pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result Result metadata, @@ -1517,7 +1517,7 @@ fn canonical_preview_path(root: &Path, file_path: &Path) -> Result Option { @@ -1659,4 +1659,30 @@ mod tests { assert!(resolve_preview_path(root.path(), "/.agent/secret.json").is_err()); assert!(resolve_preview_path(root.path(), "/memory/private.md").is_err()); } + + #[test] + fn legacy_layout_serves_root_ui_modules() { + let root = tempfile::tempdir().expect("create preview root"); + fs::create_dir_all(root.path().join("game")).expect("create game directory"); + fs::create_dir_all(root.path().join("ui")).expect("create ui directory"); + fs::write(root.path().join("game/index.html"), "") + .expect("write game entry"); + fs::write(root.path().join("ui/generated-x.js"), "export {};") + .expect("write generated module"); + + let expected = root + .path() + .join("ui/generated-x.js") + .canonicalize() + .expect("canonical generated module"); + assert_eq!( + resolve_preview_path(root.path(), "/ui/generated-x.js").unwrap(), + expected + ); + + let response = build_preview_response(root.path(), "GET", "/ui/generated-x.js"); + let response_text = String::from_utf8_lossy(&response); + assert!(response_text.starts_with("HTTP/1.1 200 OK\r\n")); + assert!(response_text.contains("export {};")); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index f7e12fd44..6c6f9fdbf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -439,6 +439,26 @@ pub(super) fn ping_external_agent_runner( .map(|_| ()) } +pub(super) fn ping_external_agent_runner_with_timeout( + endpoint: &ExternalAgentRunnerEndpoint, + timeout: Duration, +) -> Result<(), String> { + if timeout.is_zero() { + return Err("Agent Runner ping 超时预算已耗尽".to_string()); + } + send_external_agent_runner_request_with_protocol_and_id_and_timeouts( + endpoint, + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + random_identifier(b"genarrative-agent-runner-start-ping-id")?, + "runner.ping", + ExternalAgentRunnerRequestParams::default(), + EXTERNAL_AGENT_RUNNER_CONNECT_TIMEOUT.min(timeout), + timeout, + timeout, + ) + .map(|_| ()) +} + pub(super) fn retire_incompatible_external_agent_runner( endpoint_path: &Path, endpoint: &ExternalAgentRunnerEndpoint, @@ -1251,10 +1271,15 @@ pub(super) fn wait_for_external_agent_runner( let endpoint_path = external_agent_runner_endpoint_path(config_dir); let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT; loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("外部 Agent Runner 未在启动期限内就绪".to_string()); + } if let Some(endpoint) = read_current_external_agent_runner_endpoint(&endpoint_path, executable_fingerprint) { - if ping_external_agent_runner(&endpoint).is_ok() { + let ping_timeout = EXTERNAL_AGENT_RUNNER_IO_TIMEOUT.min(remaining); + if ping_external_agent_runner_with_timeout(&endpoint, ping_timeout).is_ok() { return Ok(endpoint); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 2941e825d..ee56cb6e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -282,6 +282,18 @@ fn runner_start_timeout_covers_cold_debug_binary_fingerprinting() { assert!(EXTERNAL_AGENT_RUNNER_START_TIMEOUT >= Duration::from_secs(30)); } +#[test] +fn startup_runner_ping_timeout_rejects_an_exhausted_budget() { + let endpoint = test_endpoint( + "startup-ping-timeout-token", + "startup-ping-timeout-boot", + 31_338, + ); + let error = ping_external_agent_runner_with_timeout(&endpoint, Duration::ZERO) + .expect_err("an exhausted startup budget must not attempt a runner ping"); + assert!(error.contains("超时预算已耗尽"), "{error}"); +} + #[test] fn forced_runner_drain_deadline_precedes_gui_hard_kill_deadline() { assert!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs new file mode 100644 index 000000000..e2c74cb7e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs @@ -0,0 +1,94 @@ +use maud::{Markup, PreEscaped}; + +pub(super) fn font_face_rule( + font: &crate::ui_editor::resource::font::FontAsset, + family: &str, +) -> Result { + let path = asset_url(&font.path)?; + Ok(format!("@font-face{{font-family:'{family}';src:url('{path}') format('{}');font-style:{};font-weight:{};}}", font.metadata.format.css_format(), if font.metadata.italic { "italic" } else { "normal" }, font.metadata.weight)) +} + +pub(super) fn html_comment(label: &str, value: serde_json::Value) -> Markup { + let mut text = serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string()); + while text.contains("--") { + text = text.replace("--", "- -"); + } + PreEscaped(format!("")) +} + +pub(super) fn asset_url(path: &str) -> Result { + let path = path.trim(); + if path.is_empty() + || path.starts_with('/') + || path.contains('\\') + || path.split('/').any(|part| part == "..") + || path.chars().any(|character| { + character.is_control() || matches!(character, '<' | '>' | '\'' | '"' | '`' | '(' | ')') + }) + { + return Err(format!("资源路径无效:{path}")); + } + Ok(format!("/{path}")) +} + +pub(super) fn finite(value: f32, field: &str) -> Result { + if value.is_finite() { + Ok(value) + } else { + Err(format!("{field} 必须是有限数值")) + } +} + +pub(super) fn finite_positive(value: f32, field: &str) -> Result { + finite(value, field).and_then(|v| { + if v > 0.0 { + Ok(v) + } else { + Err(format!("{field} 必须大于 0")) + } + }) +} + +pub(super) fn trim_float(value: f32) -> String { + let text = format!("{value:.4}"); + text.trim_end_matches('0').trim_end_matches('.').to_string() +} + +#[cfg(test)] +mod tests { + use super::font_face_rule; + use crate::ui_editor::resource::font::{FontAsset, FontAssetMetadata, FontFormat}; + use crate::ui_editor::utils::FontAssetId; + + fn font(format: FontFormat) -> FontAsset { + FontAsset { + asset_id: FontAssetId::new("font-main").expect("valid font id"), + metadata: FontAssetMetadata { + family_name: "测试字体".to_string(), + face_name: "Regular".to_string(), + weight: 400, + italic: false, + format, + source_file_name: format!("font.{}", format.extension()), + }, + path: format!("assets/font.{}", format.extension()), + content_sha256: "0".repeat(64), + } + } + + #[test] + fn font_face_rule_uses_standard_css_format_names() { + let truetype = font_face_rule(&font(FontFormat::TrueType), "ui-font").unwrap(); + let opentype = font_face_rule(&font(FontFormat::OpenType), "ui-font").unwrap(); + assert!(truetype.contains("format('truetype')")); + assert!(opentype.contains("format('opentype')")); + } +} + +pub(super) fn hex_id(value: &str) -> String { + value + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs new file mode 100644 index 000000000..7149afe45 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs @@ -0,0 +1,228 @@ +use crate::ui_editor::component::image::{ + FillMethod, HorizontalFillOrigin, ImageType, Radial180Origin, Radial360Origin, Radial90Origin, + VerticalFillOrigin, +}; +use crate::ui_editor::html_renderer::assets::{finite_positive, trim_float}; + +const UI_SCALE: &str = "var(--ui-scale, 1)"; + +pub(in crate::ui_editor::html_renderer) fn image_styles( + image_type: &ImageType, + sprite: &crate::ui_editor::resource::sprite::SpriteAsset, + src: &str, +) -> Result<(String, Option), String> { + match image_type { + ImageType::Simple { preserve_aspect } => Ok(( + "position:absolute;inset:0;overflow:hidden;".to_string(), + Some(format!( + "width:100%;height:100%;display:block;object-fit:{};", + if *preserve_aspect { "contain" } else { "fill" } + )), + )), + ImageType::Filled { + preserve_aspect, + method, + amount, + } => { + let mut image = format!( + "width:100%;height:100%;display:block;object-fit:{};", + if *preserve_aspect { "contain" } else { "fill" } + ); + if let Some(clip) = fill_clip_path(method, amount.clamp(0.0, 1.0)) { + if clip.starts_with("conic-gradient") { + image.push_str(&format!("mask-image:{clip};-webkit-mask-image:{clip};")); + } else { + image.push_str(&format!("clip-path:{clip};")); + } + } + Ok(( + "position:absolute;inset:0;overflow:hidden;".to_string(), + Some(image), + )) + } + ImageType::Tiled { + pixels_per_unit_multiplier, + .. + } => { + // TODO: Tiled.fill_center=false 当前暂不支持,保持整块 repeat fallback。 + let w = finite_positive( + sprite.pixel_size.x + / (sprite.pixels_per_unit().get() * pixels_per_unit_multiplier.get()), + "tile width", + )?; + let h = finite_positive( + sprite.pixel_size.y + / (sprite.pixels_per_unit().get() * pixels_per_unit_multiplier.get()), + "tile height", + )?; + Ok((format!("position:absolute;inset:0;background-image:url(\"{src}\");background-repeat:repeat;background-position:top left;background-size:calc({}px * {}) calc({}px * {});overflow:hidden;", trim_float(w), UI_SCALE, trim_float(h), UI_SCALE), None)) + } + ImageType::Sliced { + fill_center, + pixels_per_unit_multiplier, + } => { + let border = sprite.border(); + let scale = finite_positive( + sprite.pixels_per_unit().get() * pixels_per_unit_multiplier.get(), + "slice scale", + )?; + let widths = [ + border.top() as f32 / scale, + border.right() as f32 / scale, + border.bottom() as f32 / scale, + border.left() as f32 / scale, + ]; + Ok((format!("position:absolute;inset:0;overflow:hidden;border-style:solid;border-width:calc({}px * {}) calc({}px * {}) calc({}px * {}) calc({}px * {});border-image-source:url(\"{src}\");border-image-slice:{} {} {} {}{};border-image-width:calc({}px * {}) calc({}px * {}) calc({}px * {}) calc({}px * {});border-image-repeat:stretch;", trim_float(widths[0]), UI_SCALE, trim_float(widths[1]), UI_SCALE, trim_float(widths[2]), UI_SCALE, trim_float(widths[3]), UI_SCALE, border.top(), border.right(), border.bottom(), border.left(), if *fill_center { " fill" } else { "" }, trim_float(widths[0]), UI_SCALE, trim_float(widths[1]), UI_SCALE, trim_float(widths[2]), UI_SCALE, trim_float(widths[3]), UI_SCALE), None)) + } + } +} + +fn fill_clip_path(method: &FillMethod, amount: f32) -> Option { + if amount >= 1.0 { + return None; + } + if amount <= 0.0 { + return Some("inset(0 0 0 100%)".to_string()); + } + match method { + FillMethod::Horizontal(HorizontalFillOrigin::Left) => Some(format!( + "inset(0 {}% 0 0)", + trim_float((1.0 - amount) * 100.0) + )), + FillMethod::Horizontal(HorizontalFillOrigin::Right) => Some(format!( + "inset(0 0 0 {}%)", + trim_float((1.0 - amount) * 100.0) + )), + FillMethod::Vertical(VerticalFillOrigin::Top) => Some(format!( + "inset(0 0 {}% 0)", + trim_float((1.0 - amount) * 100.0) + )), + FillMethod::Vertical(VerticalFillOrigin::Bottom) => Some(format!( + "inset({}% 0 0 0)", + trim_float((1.0 - amount) * 100.0) + )), + FillMethod::Radial90 { origin, clockwise } => Some(conic_mask( + radial_origin_angle_90(*origin) - if *clockwise { 90.0 } else { 0.0 }, + *clockwise, + amount, + 90.0, + )), + FillMethod::Radial180 { origin, clockwise } => Some(conic_mask( + radial_origin_angle_180(*origin) - if *clockwise { 90.0 } else { 0.0 }, + *clockwise, + amount, + 180.0, + )), + FillMethod::Radial360 { origin, clockwise } => Some(conic_mask( + radial_origin_angle_360(*origin) - if *clockwise { 90.0 } else { 0.0 }, + *clockwise, + amount, + 360.0, + )), + } +} + +#[cfg(test)] +mod tests { + use super::{fill_clip_path, FillMethod, Radial180Origin, Radial360Origin, Radial90Origin}; + + #[test] + fn clockwise_radial_180_and_360_start_ninety_degrees_before_origin() { + let radial_180 = fill_clip_path( + &FillMethod::Radial180 { + origin: Radial180Origin::Top, + clockwise: true, + }, + 0.5, + ) + .unwrap(); + let radial_360 = fill_clip_path( + &FillMethod::Radial360 { + origin: Radial360Origin::Right, + clockwise: true, + }, + 0.25, + ) + .unwrap(); + assert!(radial_180.contains("from -90deg")); + assert!(radial_360.contains("from 0deg")); + } + + #[test] + fn counter_clockwise_radial_start_angles_remain_unchanged() { + let radial_180 = fill_clip_path( + &FillMethod::Radial180 { + origin: Radial180Origin::Top, + clockwise: false, + }, + 0.5, + ) + .unwrap(); + assert!(radial_180.contains("from -90deg")); + } + + #[test] + fn radial_90_matches_preview_for_all_origins_and_directions() { + let cases = [ + (Radial90Origin::TopLeft, 0.0), + (Radial90Origin::TopRight, 90.0), + (Radial90Origin::BottomRight, 180.0), + (Radial90Origin::BottomLeft, 270.0), + ]; + for (origin, angle) in cases { + let clockwise = fill_clip_path( + &FillMethod::Radial90 { + origin, + clockwise: true, + }, + 0.5, + ) + .unwrap(); + let counter_clockwise = fill_clip_path( + &FillMethod::Radial90 { + origin, + clockwise: false, + }, + 0.5, + ) + .unwrap(); + assert!(clockwise.contains(&format!("from {}deg", angle - 90.0))); + assert!(counter_clockwise.contains(&format!("from {}deg", angle - 45.0))); + } + } +} + +fn conic_mask(origin: f32, clockwise: bool, amount: f32, max_sweep: f32) -> String { + let sweep = amount * max_sweep; + let start = if clockwise { origin } else { origin - sweep }; + format!( + "conic-gradient(from {}deg, #000 0deg {}deg, transparent {}deg 360deg)", + trim_float(start), + trim_float(sweep), + trim_float(sweep) + ) +} +fn radial_origin_angle_90(origin: Radial90Origin) -> f32 { + match origin { + Radial90Origin::TopLeft => 0.0, + Radial90Origin::TopRight => 90.0, + Radial90Origin::BottomRight => 180.0, + Radial90Origin::BottomLeft => 270.0, + } +} +fn radial_origin_angle_180(origin: Radial180Origin) -> f32 { + match origin { + Radial180Origin::Top => 0.0, + Radial180Origin::Right => 90.0, + Radial180Origin::Bottom => 180.0, + Radial180Origin::Left => 270.0, + } +} +fn radial_origin_angle_360(origin: Radial360Origin) -> f32 { + match origin { + Radial360Origin::Top => 0.0, + Radial360Origin::Right => 90.0, + Radial360Origin::Bottom => 180.0, + Radial360Origin::Left => 270.0, + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/mod.rs new file mode 100644 index 000000000..4c6b460d9 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/mod.rs @@ -0,0 +1,60 @@ +use super::assets::{asset_url, hex_id}; +use crate::ui_editor::component::text::FontSource; +use crate::ui_editor::component::Component; +use crate::ui_editor::state::State; +use image::image_styles; +use maud::{html, Markup}; +use text::text_style; + +mod image; +mod text; + +pub(super) fn render_component(state: &State, component: &Component) -> Result { + match component { + Component::Text(text) => { + let mut style = text_style( + text.font_style, + text.alignment, + text.font_sizing, + text.color, + text.line_spacing.get(), + text.horizontal_overflow, + text.vertical_overflow, + ); + if let FontSource::Bound(font_id) = &text.font { + state + .font_assets + .get(font_id) + .ok_or_else(|| format!("Text 缺少字体资源:{}", font_id.as_str()))?; + let family = format!("ui-editor-font-{}", hex_id(font_id.as_str())); + style.push_str(&format!("font-family:'{family}';")); + return Ok(html! { + div style=(style) { + (text.content) + } + }); + } + Ok(html! { div style=(style) { (text.content) } }) + } + Component::Image(image) => { + let sprite_id = image + .target_graphic + .as_ref() + .ok_or_else(|| "Image 缺少 target_graphic".to_string())?; + let sprite = state + .sprite_assets + .get(sprite_id) + .ok_or_else(|| format!("Image 缺少 Sprite 资源:{}", sprite_id.as_str()))?; + let src = asset_url(&sprite.path)?; + let (style, image_style) = image_styles(&image.image_type, sprite, &src)?; + Ok(match image_style { + Some(image_style) => html! { + div style=(style) { + img src=(src) alt="" aria-hidden="true" draggable="false" style=(image_style); + } + }, + None => html! { div style=(style) {} }, + }) + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/text.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/text.rs new file mode 100644 index 000000000..9fcaa1606 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/text.rs @@ -0,0 +1,77 @@ +use crate::ui_editor::component::text::{ + FontSizing, FontStyle, HorizontalTextOverflow, TextAlignment, VerticalTextOverflow, +}; + +const UI_SCALE: &str = "var(--ui-scale, 1)"; + +pub(in crate::ui_editor::html_renderer) fn text_style( + font_style: FontStyle, + alignment: TextAlignment, + sizing: FontSizing, + color: [u8; 4], + line_spacing: f32, + horizontal_overflow: HorizontalTextOverflow, + vertical_overflow: VerticalTextOverflow, +) -> String { + let [r, g, b, a] = color; + let (justify, align, text_align) = match alignment { + TextAlignment::UpperLeft | TextAlignment::MiddleLeft | TextAlignment::LowerLeft => ( + "flex-start", + if matches!(alignment, TextAlignment::MiddleLeft) { + "center" + } else if matches!(alignment, TextAlignment::LowerLeft) { + "flex-end" + } else { + "flex-start" + }, + "left", + ), + TextAlignment::UpperCenter | TextAlignment::MiddleCenter | TextAlignment::LowerCenter => ( + "center", + if matches!(alignment, TextAlignment::MiddleCenter) { + "center" + } else if matches!(alignment, TextAlignment::LowerCenter) { + "flex-end" + } else { + "flex-start" + }, + "center", + ), + _ => ( + "flex-end", + if matches!(alignment, TextAlignment::MiddleRight) { + "center" + } else if matches!(alignment, TextAlignment::LowerRight) { + "flex-end" + } else { + "flex-start" + }, + "right", + ), + }; + let (weight, italic) = match font_style { + FontStyle::Normal => (400, "normal"), + FontStyle::Bold => (700, "normal"), + FontStyle::Italic => (400, "italic"), + FontStyle::BoldItalic => (700, "italic"), + }; + let size = match sizing { + FontSizing::Fixed(value) => value.get(), + FontSizing::BestFit(range) => range.min().get(), + }; + let (white_space, overflow, wrap) = match (horizontal_overflow, vertical_overflow) { + (HorizontalTextOverflow::Wrap, VerticalTextOverflow::Truncate) => { + ("normal", "hidden", "anywhere") + } + (HorizontalTextOverflow::Wrap, VerticalTextOverflow::Overflow) => { + ("normal", "visible", "anywhere") + } + (HorizontalTextOverflow::Overflow, VerticalTextOverflow::Truncate) => { + ("nowrap", "hidden", "normal") + } + (HorizontalTextOverflow::Overflow, VerticalTextOverflow::Overflow) => { + ("nowrap", "visible", "normal") + } + }; + format!("position:absolute;inset:0;display:flex;width:100%;height:100%;box-sizing:border-box;padding:0;color:rgba({r},{g},{b},{});font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;font-size:calc({size}px * {UI_SCALE});font-weight:{weight};font-style:{italic};align-items:{align};justify-content:{justify};text-align:{text_align};line-height:{};white-space:{white_space};overflow:{overflow};overflow-wrap:{wrap};", f32::from(a) / 255.0, line_spacing) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs new file mode 100644 index 000000000..c4b2dcb9d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs @@ -0,0 +1,85 @@ +use super::assets::trim_float; +use crate::ui_editor::layout::control_layout::{Container, ControlLayout}; + +const UI_SCALE: &str = "var(--ui-scale, 1)"; + +pub(super) fn container_style(container: &Container) -> Result { + Ok(match container { + Container::None => String::new(), + Container::HBox { alignment, separation } => format!("display:flex;flex-direction:row;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation)?), + Container::VBox { alignment, separation } => format!("display:flex;flex-direction:column;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation)?), + Container::Grid { columns, h_separation, v_separation } => format!("display:grid;grid-template-columns:repeat({},minmax(0,1fr));column-gap:{};row-gap:{};min-width:0;min-height:0;", columns, scaled_px(*h_separation)?, scaled_px(*v_separation)?), + Container::Margin { margin_left, margin_top, margin_right, margin_bottom } => format!("display:grid;padding-left:{};padding-top:{};padding-right:{};padding-bottom:{};min-width:0;min-height:0;", scaled_px(*margin_left)?, scaled_px(*margin_top)?, scaled_px(*margin_right)?, scaled_px(*margin_bottom)?), + Container::Center { .. } => "display:grid;place-items:center;min-width:0;min-height:0;".to_string(), + }) +} + +pub(super) fn child_container_style( + layout: &ControlLayout, + parent: &Container, +) -> Result { + let min_w = scaled_px(layout.custom_minimum_size.x)?; + let min_h = scaled_px(layout.custom_minimum_size.y)?; + Ok(match parent { + Container::HBox { .. } => format!( + "min-width:{min_w};min-height:{min_h};{}align-self:{};", + flex_grow( + layout.size_flags_horizontal, + layout.size_flags_stretch_ratio + ), + cross_axis(layout.size_flags_vertical) + ), + Container::VBox { .. } => format!( + "min-width:{min_w};min-height:{min_h};{}align-self:{};", + flex_grow(layout.size_flags_vertical, layout.size_flags_stretch_ratio), + cross_axis(layout.size_flags_horizontal) + ), + Container::Margin { .. } => { + "grid-area:1 / 1;align-self:stretch;justify-self:stretch;".to_string() + } + Container::Center { use_top_left } => { + if *use_top_left { + "justify-self:center;align-self:center;transform:translate(50%,50%);".to_string() + } else { + "justify-self:center;align-self:center;".to_string() + } + } + Container::Grid { .. } | Container::None => { + format!("min-width:{min_w};min-height:{min_h};") + } + }) +} + +fn scaled_px(value: f32) -> Result { + if !value.is_finite() { + return Err("layout px 必须是有限数值".to_string()); + } + Ok(format!("calc({}px * {})", trim_float(value), UI_SCALE)) +} +fn alignment_css( + value: crate::ui_editor::layout::control_layout::ContainerAlignment, +) -> &'static str { + match value { + crate::ui_editor::layout::control_layout::ContainerAlignment::Begin => "flex-start", + crate::ui_editor::layout::control_layout::ContainerAlignment::Center => "center", + crate::ui_editor::layout::control_layout::ContainerAlignment::End => "flex-end", + } +} +fn cross_axis(value: u8) -> &'static str { + if value & 1 != 0 { + "stretch" + } else if value == 4 { + "center" + } else if value == 8 { + "flex-end" + } else { + "flex-start" + } +} +fn flex_grow(flags: u8, ratio: f32) -> String { + if flags & 2 != 0 && ratio.is_finite() && ratio >= 0.0 { + format!("flex-grow:{};", trim_float(ratio)) + } else { + String::new() + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs new file mode 100644 index 000000000..e0c8199e7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs @@ -0,0 +1,286 @@ +mod assets; +mod component; +mod container; +mod node; + +use self::assets::{font_face_rule, hex_id, html_comment, trim_float}; +use self::component::render_component; +use self::container::{child_container_style, container_style}; +use self::node::{is_container, transform_style}; +use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; +use crate::ui_editor::layout::control_layout::Container; +use crate::ui_editor::layout::node::Node; +use crate::ui_editor::state::{State, UITree}; +use maud::{html, Markup, PreEscaped}; +use serde_json::json; + +pub(crate) fn render_ui_design_state_html(state: &State) -> Result { + let mut trees = Vec::with_capacity(state.ui_trees.len()); + for (index, tree) in state.ui_trees.iter().enumerate() { + if index > 0 { + trees.push("\n\n".to_string()); + } + trees.push(render_tree(state, tree)?.into_string()); + } + let font_faces = state + .font_assets + .values() + .map(|font| { + let family = format!("ui-editor-font-{}", hex_id(font.asset_id.as_str())); + font_face_rule(font, &family) + }) + .collect::, _>>()? + .join(""); + let fragment_comment = html_comment( + "genarrative-ui-fragment", + json!({ + "format": "html-fragment", + "treeCount": state.ui_trees.len(), + }), + ); + let fonts = (!font_faces.is_empty()).then(|| { + html! { + style data-ui-fonts { (PreEscaped(font_faces)) } + } + .into_string() + }); + let mut fragment = String::new(); + fragment.push_str(&fragment_comment.into_string()); + if let Some(fonts) = fonts { + fragment.push_str(&fonts); + } + fragment.push_str(&trees.concat()); + Ok(fragment) +} + +pub(crate) fn render_ui_design_state_js( + state: &State, +) -> Result<(String, Vec, usize), String> { + // 生成阶段只渲染已由保存流程 validate_state 校验过的 State;不重复执行领域校验。 + let font_faces = state + .font_assets + .values() + .map(|font| { + let family = format!("ui-editor-font-{}", hex_id(font.asset_id.as_str())); + font_face_rule(font, &family) + }) + .collect::, _>>()? + .join(""); + let mut exports = Vec::with_capacity(state.ui_trees.len()); + let mut modules = Vec::with_capacity(state.ui_trees.len()); + let mut node_count = 0usize; + for tree in &state.ui_trees { + let export_name = format!( + "{}_{}", + tree_export_name(tree.src_ui_design.as_str()), + hex_id(tree.root.id.as_str()) + ); + exports.push(export_name.clone()); + let image = state + .ui_design_images + .get(&tree.src_ui_design) + .ok_or_else(|| format!("UITree 缺少 src_ui_design:{}", tree.src_ui_design.as_str()))?; + let tree_comment = html_comment( + "genarrative-ui-tree", + json!({ + "srcUiDesign": tree.src_ui_design.as_str(), + "name": image.metadata.name, + "description": image.metadata.description, + }), + ) + .into_string(); + let root = render_node_with_scale( + state, + &tree.root, + None, + Some((image.pixel_size.x, image.pixel_size.y)), + )? + .into_string(); + node_count += count_nodes(&tree.root); + let mut fragment = String::new(); + fragment.push_str(&tree_comment); + if !font_faces.is_empty() { + fragment.push_str( + &html! { style data-ui-fonts { (PreEscaped(font_faces.as_str())) } }.into_string(), + ); + } + fragment.push_str(&root); + let escaped = escape_template_literal(&pretty_html_fragment(&fragment)); + modules.push(format!("export const {export_name} = `\n{escaped}\n`;")); + } + let mut output = String::from( + r#" +//这是从用户手动调整过的UI设计文档生成的html片段, 需要在合适的位置注入游戏中. +// 注入示例: +// import { tree_example } from '/ui/generated-xxx.js'; // 统一使用绝对路径这里 +// document.body.insertAdjacentHTML('beforeend', tree_example); +// 生成规则默认根节点(们)占据整个窗口. +// +// 禁止直接修改本js模块因为它随时会被用户重新导出覆盖 +// 应用逻辑/修改方式, 使用稳定的ui-node-id来查找某个元素然后使用js修改. +// const node = document.querySelector('[ui-node-id="..."]'); +// +// html片段内注释包含了丰富的元数据需要你理解并且尽可能实现它们 +// 对于交互逻辑, 页面关系, 有不清楚的地方可以向用户询问 +// 对于动态内容, 此文档给出的可能只是一个(或几个在列表中)例子, 需要你根据游戏逻辑, 在js中动态实现. +// 对于容器式布局, 文档可能错误地使用position来描述, 需要你合理地使用flex, grid, scrollable, overflow等重写. +// +// 必要时鼓励清理冗余的示例性的元素, 原则: 尽量保留复用原来的父级面板, patch其中的元素, 而不是替换整个面板 +"#, + ); + + output.push_str(&modules.join("\n\n")); + if !output.ends_with('\n') { + output.push('\n'); + } + Ok((output, exports, node_count)) +} + +fn render_tree(state: &State, tree: &UITree) -> Result { + let image = state + .ui_design_images + .get(&tree.src_ui_design) + .ok_or_else(|| format!("UITree 缺少 src_ui_design:{}", tree.src_ui_design.as_str()))?; + let tree_comment = html_comment( + "genarrative-ui-tree", + json!({ + "srcUiDesign": tree.src_ui_design.as_str(), + }), + ); + let style = "position:relative;width:100%;height:100%;min-height:0;"; + Ok(html! { + (tree_comment) + div style=(style) { (render_node(state, &tree.root, None)?) } + }) +} + +fn render_node( + state: &State, + node: &Node, + parent_container: Option<&Container>, +) -> Result { + render_node_with_scale(state, node, parent_container, None) +} + +fn render_node_with_scale( + state: &State, + node: &Node, + parent_container: Option<&Container>, + root_scale: Option<(f32, f32)>, +) -> Result { + let mut style = transform_style(&node.layout, parent_container.is_some())?; + if let Some((width, height)) = root_scale { + if !width.is_finite() || width <= 0.0 || !height.is_finite() || height <= 0.0 { + return Err("UI 设计尺寸必须为正有限数值".to_string()); + } + style.push_str(&format!( + "--ui-scale:min(calc(100vw / {}px),calc(100vh / {}px));", + trim_float(width), + trim_float(height) + )); + } + style.push_str("border:0;outline:0;background:transparent;overflow:visible;"); + style.push_str(&container_style(&node.layout.container)?); + if let Some(parent) = parent_container { + style.push_str(&child_container_style(&node.layout, parent)?); + } + let comment = html_comment( + "genarrative-ui-node", + json!({ + "nodeId": node.id.as_str(), + "name": node.metadata.name, + "description": node.metadata.description, + }), + ); + let exclusive_comment = matches!(node.children_display_mode, ChildrenDisplayMode::Exclusive) + .then(|| { + html_comment( + "genarrative-ui-node-group", + json!({"childrenDisplayMode": "Exclusive", "childrenRendered": "all"}), + ) + }); + let components = node + .components + .iter() + .map(|component| render_component(state, component)) + .collect::, _>>()? + .into_iter() + .map(|fragment| fragment.into_string()) + .collect::>(); + let children = node + .children + .iter() + .map(|child| { + render_node_with_scale( + state, + child, + is_container(&node.layout.container).then_some(&node.layout.container), + None, + ) + }) + .collect::, _>>()? + .into_iter() + .map(|fragment| fragment.into_string()) + .collect::>(); + Ok(html! { + (comment) + @if let Some(group_comment) = exclusive_comment { (group_comment) } + div ui-node-id=(node.id.as_str()) style=(style) { + (PreEscaped(components.concat())) + (PreEscaped(children.concat())) + } + }) +} + +fn count_nodes(node: &Node) -> usize { + 1 + node.children.iter().map(count_nodes).sum::() +} + +fn tree_export_name(id: &str) -> String { + let mut name = String::from("tree_"); + for character in id.chars() { + if character.is_ascii_alphanumeric() || character == '_' { + name.push(character); + } else { + name.push('_'); + } + } + name +} + +fn escape_template_literal(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('`', "\\`") + .replace("${", "\\${") +} + +fn pretty_html_fragment(value: &str) -> String { + let mut depth = 0usize; + let mut lines = Vec::new(); + for raw in value.replace("><", ">\n<").lines() { + let line = raw.trim(); + if line.is_empty() { + continue; + } + let closing = line.starts_with("") + && !line.starts_with(" Result { + let transform = &layout.transform; + for value in transform + .anchor_min + .iter() + .chain(transform.anchor_max.iter()) + .chain(transform.offset_min.iter()) + .chain(transform.offset_max.iter()) + { + if !value.is_finite() { + return Err("Transform 包含非有限数值".to_string()); + } + } + if transform.anchor_min.x > transform.anchor_max.x + || transform.anchor_min.y > transform.anchor_max.y + { + return Err("Transform anchors must be ordered".to_string()); + } + if in_container { + return Ok(format!( + "position:relative;min-width:{};min-height:{};", + scaled_px(layout.custom_minimum_size.x)?, + scaled_px(layout.custom_minimum_size.y)? + )); + } + Ok(format!( + "position:absolute;left:{};top:{};right:{};bottom:{};", + css_length(transform.anchor_min.x * 100.0, transform.offset_min.x)?, + css_length(transform.anchor_min.y * 100.0, transform.offset_min.y)?, + css_length( + (1.0 - transform.anchor_max.x) * 100.0, + -transform.offset_max.x + )?, + css_length( + (1.0 - transform.anchor_max.y) * 100.0, + -transform.offset_max.y + )? + )) +} + +fn css_length(percent: f32, offset: f32) -> Result { + if !percent.is_finite() || !offset.is_finite() { + return Err("Transform 无法转换为 CSS".to_string()); + } + if percent == 0.0 { + return Ok(format!("calc({}px * {})", trim_float(offset), UI_SCALE)); + } + if offset == 0.0 { + return Ok(format!("{}%", trim_float(percent))); + } + Ok(format!( + "calc({}% {} {}px * {})", + trim_float(percent), + if offset < 0.0 { '-' } else { '+' }, + trim_float(offset.abs()), + UI_SCALE + )) +} + +fn scaled_px(value: f32) -> Result { + finite(value, "layout px")?; + Ok(format!("calc({}px * {})", trim_float(value), UI_SCALE)) +} + +pub(super) fn is_container( + container: &crate::ui_editor::layout::control_layout::Container, +) -> bool { + !matches!( + container, + crate::ui_editor::layout::control_layout::Container::None + ) +} 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 index d002c7611..51fb28ec3 100644 --- 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 @@ -1,5 +1,6 @@ pub mod commands; pub mod component; +pub(crate) mod html_renderer; pub mod layout; pub mod persistence; pub mod resource; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index 9557d24b2..04b72c0b3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -1,5 +1,6 @@ use crate::ui_editor::component::text::FontSource; use crate::ui_editor::component::Component; +use crate::ui_editor::html_renderer::render_ui_design_state_js; use crate::ui_editor::layout::node::Node; use crate::ui_editor::resource::ui_design_image::{ UIDesignImage, UIDesignImageMetadata, UIDesignImageRole, @@ -9,6 +10,7 @@ use crate::ui_editor::utils::UIDesignImageId; use crate::*; use nalgebra::Vector2; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::HashSet; use std::fs::{self, File}; use std::io::{Read, Write}; @@ -18,6 +20,7 @@ use typed_floats::tf32::StrictlyPositiveFinite; const UI_DESIGN_STATE_SCHEMA_VERSION: &str = "game-creator-ui-design-state.v1"; const UI_DESIGN_STATE_MAX_BYTES: usize = 2 * 1024 * 1024; +const UI_DESIGN_CODE_MAX_BYTES: usize = UI_DESIGN_STATE_MAX_BYTES * 8; const UI_DESIGN_STATE_MAX_IMAGES: usize = 4; const UI_DESIGN_STATE_MAX_SPRITES: usize = 1_024; pub(crate) const UI_DESIGN_STATE_MAX_NODES: usize = 10_000; @@ -43,6 +46,23 @@ pub(crate) struct LoadUiDesignStateInput { pub(crate) asset_id: String, } +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct GenerateUiDesignCodeInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) asset_id: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GenerateUiDesignCodeResult { + pub(crate) relative_path: String, + pub(crate) tree_exports: Vec, + pub(crate) tree_count: usize, + pub(crate) node_count: usize, +} + #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct SaveUiDesignStateInput { @@ -149,6 +169,52 @@ pub(crate) fn load_ui_design_state_at( }) } +pub(crate) fn generate_ui_design_code_at( + input: GenerateUiDesignCodeInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; + let asset_id = required_identifier(&input.asset_id, "assetId")?; + let _lock = acquire_project_write_lock(root, "ui_design.code_generate")?; + let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let document = + read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?; + let (content, tree_exports, node_count) = render_ui_design_state_js(&document.state)?; + let relative_path = format!("ui/generated-{}.js", generated_file_stem(&asset_id)); + let path = resolve_local_project_path(root, &relative_path)?; + write_ui_design_raw_file_with_limit( + &path, + "UI 设计生成代码", + content.as_bytes(), + UI_DESIGN_CODE_MAX_BYTES, + )?; + Ok(GenerateUiDesignCodeResult { + relative_path, + tree_count: tree_exports.len(), + tree_exports, + node_count, + }) +} + +fn generated_file_stem(asset_id: &str) -> String { + let mut stem = String::new(); + for character in asset_id.chars() { + if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') { + stem.push(character); + } else { + stem.push('_'); + } + } + let readable_stem = if stem.is_empty() { + "ui-design" + } else { + stem.as_str() + }; + // 保留可读前缀,并追加摘要以避免不同 ID 映射到同一路径。 + let digest = format!("{:x}", Sha256::digest(asset_id.as_bytes())); + format!("{readable_stem}-{}", &digest[..16]) +} + pub(crate) fn save_ui_design_state_at( input: SaveUiDesignStateInput, ) -> Result { @@ -382,8 +448,17 @@ fn serialize_ui_design_document(document: &PersistedUiDesignState) -> Result.` file. fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<(), String> { - if bytes.len() > UI_DESIGN_STATE_MAX_BYTES { - return Err(format!("{label} 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限")); + write_ui_design_raw_file_with_limit(path, label, bytes, UI_DESIGN_STATE_MAX_BYTES) +} + +fn write_ui_design_raw_file_with_limit( + path: &Path, + label: &str, + bytes: &[u8], + max_bytes: usize, +) -> Result<(), String> { + if bytes.len() > max_bytes { + return Err(format!("{label} 超过 {max_bytes} 字节上限")); } let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?; fs::create_dir_all(parent).map_err(|error| format!("创建 {label} 目录失败:{error}"))?; @@ -920,6 +995,15 @@ mod tests { ); } + #[test] + fn generated_file_stem_keeps_distinct_asset_ids_distinct() { + let first = generated_file_stem("a.b"); + let second = generated_file_stem("a/b"); + assert_ne!(first, second); + assert!(first.starts_with("a_b-")); + assert_eq!(first.len(), "a_b-".len() + 16); + } + #[test] fn preserves_sprite_asset_when_saving_a_dragged_node_transform() { let (directory, asset_id) = fixture(); @@ -959,6 +1043,34 @@ mod tests { assert_eq!(loaded.state, state); } + #[test] + fn preserves_ordered_out_of_range_transform_anchors() { + let (directory, asset_id) = fixture(); + let mut state = state_with_sprite_and_dragged_transform(); + let transform = &mut state.ui_trees[0].root.children[0].layout.transform; + transform.anchor_min = Vector2::new(-0.25, -0.5); + transform.anchor_max = Vector2::new(1.5, 1.25); + + let saved = save_ui_design_state_at(input(directory.path(), &asset_id, 0, state.clone())) + .expect("save ordered out-of-range anchors"); + assert!(matches!( + saved, + SaveUiDesignStateResult::Saved { + revision: 1, + state: ref installed, + .. + } if installed == &state + )); + + let loaded = load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id, + }) + .expect("load ordered out-of-range anchors"); + assert_eq!(loaded.state, state); + } + #[test] fn rejects_inverted_transform_anchors() { let state: State = serde_json::from_value(serde_json::json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/font.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/font.rs index 776f4b2e5..70efc4131 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/font.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/font.rs @@ -30,6 +30,15 @@ impl FontFormat { Self::Woff2 => "woff2", } } + + pub fn css_format(self) -> &'static str { + match self { + Self::TrueType => "truetype", + Self::OpenType => "opentype", + Self::Woff => "woff", + Self::Woff2 => "woff2", + } + } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] @@ -210,4 +219,12 @@ mod tests { .expect_err("reject unparseable web font") .contains("无法安全解析")); } + + #[test] + fn font_format_uses_css_format_strings() { + assert_eq!(FontFormat::TrueType.css_format(), "truetype"); + assert_eq!(FontFormat::OpenType.css_format(), "opentype"); + assert_eq!(FontFormat::Woff.css_format(), "woff"); + assert_eq!(FontFormat::Woff2.css_format(), "woff2"); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json index cb9d5cc8e..ad932ab58 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json @@ -2,6 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "bundle": { "targets": ["nsis"], + "useLocalToolsDir": true, "resources": { "resources/codex/win-x64/bin/codex.exe": "codex/win-x64/bin/codex.exe", "resources/codex/win-x64/bin/codex-code-mode-host.exe": "codex/win-x64/bin/codex-code-mode-host.exe", diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index bbd074bc9..3878476a3 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -4,6 +4,7 @@ import { type FormEvent, type ReactNode, useEffect, + useRef, useState, } from 'react'; @@ -55,6 +56,36 @@ type ClientRuntimeErrorBoundaryState = { errorMessage: string; }; +type AuthCheckStage = 'token' | 'refresh' | 'me' | 'runner'; + +const AUTH_CHECK_STAGE_LABELS: Record = { + token: '读取本地登录凭据', + refresh: '刷新登录状态', + me: '确认当前用户', + runner: '连接本地运行时', +}; + +const AUTH_CHECK_REQUEST_TIMEOUT_MS = 15_000; +// Existing endpoint probes can consume the 10s IPC budget before Runner's +// 30s startup deadline. Keep the UI fence slightly above that worst case. +const AUTH_CHECK_RUNNER_TIMEOUT_MS = 45_000; + +function withAuthCheckTimeout( + promise: Promise, + timeoutMs: number, + message: string, +) { + let timeoutId: number | undefined; + const timeout = new Promise((_, reject) => { + timeoutId = window.setTimeout(() => reject(new Error(message)), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + }); +} + export class ClientRuntimeErrorBoundary extends Component< ClientRuntimeErrorBoundaryProps, ClientRuntimeErrorBoundaryState @@ -108,6 +139,11 @@ export function AuthenticatedClient({ 'checking' | 'authenticated' | 'unauthenticated' >('checking'); const [authUser, setAuthUser] = useState(null); + const [authCheckStage, setAuthCheckStage] = useState('token'); + const [authCheckElapsedSeconds, setAuthCheckElapsedSeconds] = useState(0); + const [authCheckError, setAuthCheckError] = useState(''); + const [authCheckRetryKey, setAuthCheckRetryKey] = useState(0); + const authCheckRunRef = useRef(0); const [loginMode, setLoginMode] = useState<'code' | 'password'>('code'); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); @@ -176,100 +212,162 @@ export function AuthenticatedClient({ useEffect(() => { let disposed = false; async function hydrateAuth() { + const runId = ++authCheckRunRef.current; + const isActiveRun = () => !disposed && authCheckRunRef.current === runId; const hydrationGeneration = currentPlatformSessionGeneration(); const hydrationApiBaseUrl = getClientServerBaseUrl(); + setAuthCheckError(''); + setAuthCheckStage('token'); try { if (!getStoredAuthAccessToken()) { - const refreshed = await refreshPlatformSessionForGeneration( - hydrationGeneration, - hydrationApiBaseUrl, + setAuthCheckStage('refresh'); + const refreshed = await withAuthCheckTimeout( + refreshPlatformSessionForGeneration( + hydrationGeneration, + hydrationApiBaseUrl, + ), + AUTH_CHECK_REQUEST_TIMEOUT_MS, + '刷新登录状态超时,请检查服务器地址和网络后重试', ); if (!refreshed) { + if (isActiveRun()) { + setAuthStatus('unauthenticated'); + } return; } } - const user = await getCurrentClientAuthUser(hydrationApiBaseUrl); - if (disposed) { + if (!isActiveRun()) return; + setAuthCheckStage('me'); + const user = await withAuthCheckTimeout( + getCurrentClientAuthUser(hydrationApiBaseUrl), + AUTH_CHECK_REQUEST_TIMEOUT_MS, + '读取当前用户超时,请检查服务器地址和网络后重试', + ); + if (!isActiveRun()) { return; } if (user) { - const committedGeneration = await commitAuthenticatedPlatformSession( - user, - hydrationGeneration, - hydrationApiBaseUrl, + setAuthCheckStage('runner'); + const committedGeneration = await withAuthCheckTimeout( + commitAuthenticatedPlatformSession( + user, + hydrationGeneration, + hydrationApiBaseUrl, + ), + AUTH_CHECK_RUNNER_TIMEOUT_MS, + '连接本地运行时超时,请重试或重启客户端', ); if (committedGeneration === null) { return; } - if (disposed) { + if (!isActiveRun()) { return; } setAuthUser(user); + setAuthCheckError(''); setAuthStatus('authenticated'); return; } clearStoredAuthAccessToken(); setAuthStatus('unauthenticated'); } catch (error) { - if (disposed) { + if (!isActiveRun()) { return; } if ( getStoredAuthAccessToken() && isClientAuthRecoverableCheckError(error) ) { - setLoginStatus( - getClientAuthErrorMessage(error, '登录服务暂时不可用,请稍后重试'), + const message = getClientAuthErrorMessage( + error, + '登录服务暂时不可用,请稍后重试', ); + setAuthCheckError(message); + setLoginStatus(message); setAuthStatus('unauthenticated'); return; } if (getStoredAuthAccessToken()) { try { - const refreshed = await refreshPlatformSessionForGeneration( - hydrationGeneration, - hydrationApiBaseUrl, + if (!isActiveRun()) return; + setAuthCheckStage('refresh'); + const refreshed = await withAuthCheckTimeout( + refreshPlatformSessionForGeneration( + hydrationGeneration, + hydrationApiBaseUrl, + ), + AUTH_CHECK_REQUEST_TIMEOUT_MS, + '刷新登录状态超时,请检查服务器地址和网络后重试', ); if (!refreshed) { + if (isActiveRun()) { + setAuthStatus('unauthenticated'); + } return; } - const user = await getCurrentClientAuthUser(hydrationApiBaseUrl); - if (disposed) { + if (!isActiveRun()) return; + setAuthCheckStage('me'); + const user = await withAuthCheckTimeout( + getCurrentClientAuthUser(hydrationApiBaseUrl), + AUTH_CHECK_REQUEST_TIMEOUT_MS, + '读取当前用户超时,请检查服务器地址和网络后重试', + ); + if (!isActiveRun()) { return; } if (user) { - const committedGeneration = - await commitAuthenticatedPlatformSession( + if (!isActiveRun()) return; + setAuthCheckStage('runner'); + const committedGeneration = await withAuthCheckTimeout( + commitAuthenticatedPlatformSession( user, hydrationGeneration, hydrationApiBaseUrl, - ); + ), + AUTH_CHECK_RUNNER_TIMEOUT_MS, + '连接本地运行时超时,请重试或重启客户端', + ); if (committedGeneration === null) { return; } - if (disposed) { + if (!isActiveRun()) { return; } + setAuthCheckError(''); setAuthUser(user); setAuthStatus('authenticated'); return; } } catch (retryError) { + if (!isActiveRun()) { + return; + } if ( getStoredAuthAccessToken() && isClientAuthRecoverableCheckError(retryError) ) { - setLoginStatus( - getClientAuthErrorMessage( - retryError, - '登录服务暂时不可用,请稍后重试', - ), + const message = getClientAuthErrorMessage( + retryError, + '登录服务暂时不可用,请稍后重试', ); + setAuthCheckError(message); + setLoginStatus(message); setAuthStatus('unauthenticated'); return; } } } + if (!isActiveRun()) { + return; + } + if (isClientAuthRecoverableCheckError(error)) { + const message = getClientAuthErrorMessage( + error, + '登录服务暂时不可用,请稍后重试', + ); + setAuthCheckError(message); + setLoginStatus(message); + } clearStoredAuthAccessToken(); setAuthStatus('unauthenticated'); } @@ -278,7 +376,18 @@ export function AuthenticatedClient({ return () => { disposed = true; }; - }, []); + }, [authCheckRetryKey]); + + useEffect(() => { + if (authStatus !== 'checking') { + return; + } + setAuthCheckElapsedSeconds(0); + const timer = window.setInterval(() => { + setAuthCheckElapsedSeconds((current) => current + 1); + }, 1000); + return () => window.clearInterval(timer); + }, [authStatus, authCheckRetryKey]); useEffect( () => @@ -392,6 +501,7 @@ export function AuthenticatedClient({ return; } setAuthUser(user); + setAuthCheckError(''); setAuthStatus('authenticated'); setCode(''); setPassword(''); @@ -419,6 +529,7 @@ export function AuthenticatedClient({ nativeClearError = error; } setAuthUser(null); + setAuthCheckError(''); setAuthStatus('unauthenticated'); setLoginStatus( nativeClearError @@ -428,6 +539,7 @@ export function AuthenticatedClient({ } if (authStatus === 'checking') { + const stageLabel = AUTH_CHECK_STAGE_LABELS[authCheckStage]; return (
陶泥儿

正在检查登录状态

+

+ {stageLabel} · 已等待 {authCheckElapsedSeconds} 秒 +

+

+ {authCheckElapsedSeconds >= 8 + ? '检查时间较长,请确认服务器可访问;若持续无响应可稍后重试' + : '正在恢复会话,请稍候'} +

); @@ -453,6 +573,23 @@ export function AuthenticatedClient({

登录陶泥儿 GameAgent

登录后进入首页和本地项目工作区

+ {authCheckError ? ( +
+

{authCheckError}

+ +
+ ) : null}