Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e992bcdf8 | |||
| 8368aa262c | |||
| e9ddbf16da | |||
| 22ac1f4c0b | |||
| 9910a0eec0 | |||
| 70981b9ca9 | |||
| 8a0d5600b3 | |||
| 184d88dbb8 | |||
| 791794c0a6 | |||
| 9cfb47d945 | |||
| 27859d3e19 | |||
| 43f3780a38 | |||
| a1f9149c27 | |||
| f0bba18841 | |||
| f679cfa179 | |||
| f68ffcfecd | |||
| ec94a4fe00 | |||
| 46f8592375 | |||
| cdf2882302 | |||
| d156113e71 | |||
| a8f2ac17be | |||
| 43ac9a5761 | |||
| e992e0b35c | |||
| e389b54a3a | |||
| 84c780a6f8 | |||
| 03f900fbe5 |
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main",
|
||||
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
|
||||
"description": "AI 游戏创作主窗口允许读写系统剪贴板,用于粘贴素材附件和复制生成文件路径;允许弹出原生打开/保存对话框用于素材上传与导出。",
|
||||
"windows": ["client"],
|
||||
"permissions": [
|
||||
"clipboard-manager:allow-read-image",
|
||||
"clipboard-manager:allow-read-text",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"core:image:allow-rgba",
|
||||
"core:image:allow-size",
|
||||
"core:resources:allow-close",
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
use super::*;
|
||||
use crate::ui_editor::persistence::{
|
||||
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
|
||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
};
|
||||
|
||||
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
|
||||
@@ -137,35 +133,7 @@ fn validate_resource_reference_id(value: &str) -> Result<String, String> {
|
||||
Ok(resource_id.to_string())
|
||||
}
|
||||
|
||||
/// Render the prompt context for a UI design asset.
|
||||
///
|
||||
/// Keep this separate from the generic resource renderer so UI-specific
|
||||
/// instructions/metadata can evolve without changing other asset kinds.
|
||||
fn render_ui_design_reference_line(
|
||||
root: &Path,
|
||||
manifest: &GameCreationAppManifest,
|
||||
asset: &GameCreationAppAssetManifestEntry,
|
||||
resource_id: &str,
|
||||
label: &str,
|
||||
local_path: &str,
|
||||
source: &str,
|
||||
) -> String {
|
||||
let context = match generate_ui_design_code_at(GenerateUiDesignCodeInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
expected_project_id: manifest.project_id.clone(),
|
||||
asset_id: resource_id.to_string(),
|
||||
}) {
|
||||
Ok(result) => format!("请先阅读生成的带有文档的代码片段: {}", result.relative_path),
|
||||
Err(error) => format!("生成代码遇到错误{error}"),
|
||||
};
|
||||
format!(
|
||||
"- 素材 ID:{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
|
||||
asset.kind, asset.media_type
|
||||
) + "\n" + &context
|
||||
}
|
||||
|
||||
fn render_resource_reference_line(
|
||||
root: &Path,
|
||||
manifest: &GameCreationAppManifest,
|
||||
reference: &DirectCodexResourceReference,
|
||||
) -> Result<String, String> {
|
||||
@@ -181,19 +149,6 @@ fn render_resource_reference_line(
|
||||
.unwrap_or_else(|| asset_display_label(asset));
|
||||
let source = sanitize_reference_source(reference.source.as_deref())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let is_ui_design =
|
||||
asset.kind == UI_DESIGN_DOC_ASSET_KIND && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE;
|
||||
if is_ui_design {
|
||||
return Ok(render_ui_design_reference_line(
|
||||
root,
|
||||
manifest,
|
||||
asset,
|
||||
&resource_id,
|
||||
&label,
|
||||
&local_path,
|
||||
&source,
|
||||
));
|
||||
}
|
||||
Ok(format!(
|
||||
"- 素材 ID:{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
|
||||
asset.kind, asset.media_type
|
||||
@@ -279,7 +234,7 @@ pub(crate) fn render_direct_codex_references_section(
|
||||
for reference in references {
|
||||
lines.push(match reference {
|
||||
DirectCodexTurnReference::Resource(reference) => {
|
||||
render_resource_reference_line(root, &manifest, reference)?
|
||||
render_resource_reference_line(&manifest, reference)?
|
||||
}
|
||||
DirectCodexTurnReference::RuntimeRegion(reference) => {
|
||||
render_runtime_region_reference_line(&manifest, reference)?
|
||||
|
||||
@@ -2144,10 +2144,7 @@ pub(crate) fn create_ui_design_resource(
|
||||
let next_index = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| {
|
||||
asset.kind == crate::ui_editor::persistence::UI_DESIGN_DOC_ASSET_KIND
|
||||
&& asset.media_type == crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE
|
||||
})
|
||||
.filter(|asset| asset.kind == "UI")
|
||||
.count()
|
||||
+ 1;
|
||||
let resource_name = format!("UI 设计 {next_index}");
|
||||
@@ -2181,8 +2178,8 @@ pub(crate) fn create_ui_design_resource(
|
||||
let asset = match register_local_asset_at(
|
||||
root,
|
||||
&relative_path,
|
||||
crate::ui_editor::persistence::UI_DESIGN_DOC_ASSET_KIND,
|
||||
crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
"UI",
|
||||
"application/json",
|
||||
"generated",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
|
||||
@@ -2031,8 +2031,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
|
||||
let registered = register_local_asset_at(
|
||||
&root,
|
||||
"ui/UI 设计 1.json",
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
"UI",
|
||||
"application/json",
|
||||
"ui-workflow",
|
||||
source(),
|
||||
)
|
||||
@@ -2053,8 +2053,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
|
||||
register_local_asset_at(
|
||||
&root,
|
||||
"ui/UI 设计 1.json",
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
"UI",
|
||||
"application/json",
|
||||
"ui-workflow",
|
||||
source(),
|
||||
)
|
||||
@@ -2063,10 +2063,7 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
|
||||
let manifest: Value =
|
||||
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
|
||||
.expect("manifest json");
|
||||
assert_eq!(
|
||||
manifest["assets"][0]["kind"],
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND
|
||||
);
|
||||
assert_eq!(manifest["assets"][0]["kind"], "UI");
|
||||
assert_eq!(manifest["assets"][0]["category"], "audio");
|
||||
assert_eq!(manifest["assets"][0]["tags"], serde_json::json!(["界面"]));
|
||||
|
||||
@@ -2077,7 +2074,7 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
|
||||
///
|
||||
/// 这里的字面量与写入侧逐字一致:UI 设计资产是
|
||||
/// `ui_editor/resource_bridge.rs` / `workflow.rs` / `persistence.rs` 的
|
||||
/// `register_local_asset_at` 使用 UI 文档 kind/media 常量,
|
||||
/// `register_local_asset_at(root, path, "UI", "application/json", ...)`,
|
||||
/// 字体是 `commands.rs` 字体上传的 `register_local_asset_entry(root, path, "font", ...)`。
|
||||
/// 只要别名表漏掉它们,真机资产就会永远停在「待归类」且读时自愈也救不回来。
|
||||
#[test]
|
||||
@@ -2102,8 +2099,8 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() {
|
||||
register_local_asset_at(
|
||||
&root,
|
||||
"ui/UI 设计 1.json",
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
"UI",
|
||||
"application/json",
|
||||
"ui-workflow",
|
||||
source(),
|
||||
)
|
||||
@@ -2136,11 +2133,7 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() {
|
||||
assert_eq!(
|
||||
categories,
|
||||
vec![
|
||||
(
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND
|
||||
.to_string(),
|
||||
"ui-interaction".to_string(),
|
||||
),
|
||||
("UI".to_string(), "ui-interaction".to_string()),
|
||||
("font".to_string(), "document".to_string()),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -172,6 +172,7 @@ mod materialize {
|
||||
use crate::ui_editor::layout::node::{
|
||||
Node as LayoutNode, NodeMetadata, NodeSource, StageStatus,
|
||||
};
|
||||
use crate::ui_editor::layout::offset::NodeOffset;
|
||||
use crate::ui_editor::layout::transform::Transform;
|
||||
use crate::ui_editor::state::{State, UITree};
|
||||
use crate::ui_editor::utils::{random_node_id, NodeId, UIDesignImageId};
|
||||
@@ -302,6 +303,7 @@ mod materialize {
|
||||
component: None,
|
||||
children_display_mode: ChildrenDisplayMode::Exclusive,
|
||||
children: members.into_iter().map(|member| member.node).collect(),
|
||||
offset: NodeOffset::default(),
|
||||
},
|
||||
priority,
|
||||
src_ui_design,
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||
use crate::ui_editor::layout::dimension::UIRect;
|
||||
use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, StageStatus};
|
||||
use crate::ui_editor::layout::offset::NodeOffset;
|
||||
use crate::ui_editor::layout::transform::Transform;
|
||||
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
|
||||
use crate::ui_editor::state::{State, UITree};
|
||||
@@ -322,6 +323,7 @@ fn convert_node(
|
||||
component: source.component.clone().into_option(),
|
||||
children_display_mode: ChildrenDisplayMode::Stack,
|
||||
children,
|
||||
offset: NodeOffset::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -858,6 +860,7 @@ pub(crate) async fn recognize_ui_impl_with_provider(
|
||||
component: recognition_root.component.into_option(),
|
||||
children_display_mode: ChildrenDisplayMode::Stack,
|
||||
children,
|
||||
offset: NodeOffset::default(),
|
||||
};
|
||||
// Tree identity and root identity are assigned by Rust, never chosen by the model.
|
||||
ui_trees.push(UITree {
|
||||
|
||||
+2
-29
@@ -265,11 +265,11 @@ pub fn inspect_separation_recovery(
|
||||
}
|
||||
|
||||
pub fn finalize_separation(root: &Path, asset_id: &str) -> Result<(), String> {
|
||||
remove_separation_recovery_files(root, asset_id)
|
||||
remove_separation_state(root, asset_id)
|
||||
}
|
||||
|
||||
pub fn discard_separation_recovery(root: &Path, asset_id: &str) -> Result<(), String> {
|
||||
remove_separation_recovery_files(root, asset_id)
|
||||
remove_separation_state(root, asset_id)
|
||||
}
|
||||
|
||||
fn remove_separation_state(root: &Path, asset_id: &str) -> Result<(), String> {
|
||||
@@ -281,33 +281,6 @@ fn remove_separation_state(root: &Path, asset_id: &str) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_separation_recovery_files(root: &Path, asset_id: &str) -> Result<(), String> {
|
||||
let sidecar = separation_sidecar_dir(root, asset_id)?;
|
||||
remove_separation_state(root, asset_id)?;
|
||||
let entries = match fs::read_dir(&sidecar) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(format!("读取 separation 临时文件失败:{error}")),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|error| format!("读取 separation 临时文件失败:{error}"))?;
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name.starts_with("processed-") || name.starts_with("binding-") {
|
||||
let path = entry.path();
|
||||
if entry
|
||||
.file_type()
|
||||
.map_err(|error| format!("检查 separation 临时文件失败:{error}"))?
|
||||
.is_file()
|
||||
{
|
||||
fs::remove_file(&path)
|
||||
.map_err(|error| format!("删除 separation 临时文件失败:{error}"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -2,4 +2,5 @@ pub mod children_display_mode;
|
||||
pub mod control_layout;
|
||||
pub mod dimension;
|
||||
pub mod node;
|
||||
pub mod offset;
|
||||
pub mod transform;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||
use crate::ui_editor::layout::offset::NodeOffset;
|
||||
use crate::ui_editor::utils::NodeId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
@@ -14,6 +15,7 @@ pub struct Node {
|
||||
pub component: Option<Component>,
|
||||
pub children_display_mode: ChildrenDisplayMode,
|
||||
pub children: Vec<Node>,
|
||||
pub offset: NodeOffset,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct NodeOffset {
|
||||
pub min: [f32; 2],
|
||||
pub max: [f32; 2],
|
||||
}
|
||||
|
||||
impl Default for NodeOffset {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min: [0.0, 0.0],
|
||||
max: [0.0, 0.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use typed_floats::tf32::StrictlyPositiveFinite;
|
||||
|
||||
const UI_DESIGN_STATE_SCHEMA_VERSION: &str = "game-creator-ui-design-state.v1";
|
||||
pub(crate) use shared_contracts::game_creation_app::{
|
||||
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND as UI_DESIGN_DOC_ASSET_KIND,
|
||||
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE as UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
};
|
||||
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;
|
||||
@@ -325,7 +321,7 @@ fn ui_design_asset(
|
||||
.into_iter()
|
||||
.find(|asset| asset.id == asset_id)
|
||||
.ok_or_else(|| "UI 设计资源不存在".to_string())?;
|
||||
if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE {
|
||||
if asset.kind != "UI" || asset.media_type != "application/json" {
|
||||
return Err("目标资源不是 UI 设计 JSON 资产".to_string());
|
||||
}
|
||||
normalize_relative_path(&asset.local_path)?;
|
||||
@@ -819,8 +815,8 @@ mod tests {
|
||||
let asset = register_local_asset_at(
|
||||
directory.path(),
|
||||
relative_path,
|
||||
UI_DESIGN_DOC_ASSET_KIND,
|
||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
"UI",
|
||||
"application/json",
|
||||
"test",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use crate::ui_editor::persistence::{
|
||||
initialize_ui_design_state_with_source_image_at, UI_DESIGN_DOC_ASSET_KIND,
|
||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
};
|
||||
use crate::ui_editor::persistence::initialize_ui_design_state_with_source_image_at;
|
||||
use crate::{
|
||||
acquire_project_write_lock, advance_agent_runtime_project_revision_locked,
|
||||
enforce_project_permission_policy, read_existing_manifest_for_project,
|
||||
@@ -92,8 +89,8 @@ pub(crate) fn ensure_ui_design_resource_for_prototype(
|
||||
}
|
||||
|
||||
if let Some(asset) = manifest.assets.iter().find(|asset| {
|
||||
asset.kind == UI_DESIGN_DOC_ASSET_KIND
|
||||
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
|
||||
asset.kind == "UI"
|
||||
&& asset.media_type == "application/json"
|
||||
&& asset.source.reference_resource_ids.iter().any(|reference| {
|
||||
source_reference_ids
|
||||
.iter()
|
||||
@@ -121,8 +118,8 @@ pub(crate) fn ensure_ui_design_resource_for_prototype(
|
||||
let asset = match register_local_asset_at(
|
||||
root,
|
||||
&relative_path,
|
||||
UI_DESIGN_DOC_ASSET_KIND,
|
||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
"UI",
|
||||
"application/json",
|
||||
"generated",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
@@ -196,9 +193,7 @@ fn next_ui_design_path(
|
||||
let mut index = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| {
|
||||
asset.kind == UI_DESIGN_DOC_ASSET_KIND && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
|
||||
})
|
||||
.filter(|asset| asset.kind == "UI")
|
||||
.count()
|
||||
+ 1;
|
||||
loop {
|
||||
@@ -286,9 +281,11 @@ mod tests {
|
||||
};
|
||||
let first = ensure_ui_design_resource_for_prototype(input.clone()).expect("bridge");
|
||||
assert!(first.created);
|
||||
assert_eq!(first.asset.kind, UI_DESIGN_DOC_ASSET_KIND);
|
||||
assert_eq!(first.asset.kind, "UI");
|
||||
// 写侧 → 分类的端到端断言:这条路径走的是与
|
||||
// 写侧与 persistence/workflow 共用 UI 文档 kind/media 常量。
|
||||
// `workflow.rs` / `persistence.rs` 完全相同的 `register_local_asset_at(..., "UI", ...)`。
|
||||
// 别名表漏掉大写 `UI` 时,这里会落 unclassified(真机 8 条 UI 资产的表现),
|
||||
// 且派生值本身就是 unclassified,读时自愈也救不回来。
|
||||
assert_eq!(
|
||||
first.asset.category,
|
||||
GameCreationAppAssetCategory::UiInteraction
|
||||
@@ -325,10 +322,7 @@ mod tests {
|
||||
.manifest
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| {
|
||||
asset.kind == UI_DESIGN_DOC_ASSET_KIND
|
||||
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
|
||||
})
|
||||
.filter(|asset| asset.kind == "UI")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ use crate::ui_editor::layout::node::{Node, StageStatus};
|
||||
use crate::ui_editor::persistence::{
|
||||
initialize_ui_design_state_at, load_ui_design_state_at, save_ui_design_state_at,
|
||||
LoadUiDesignStateInput, SaveUiDesignStateInput, SaveUiDesignStateResult,
|
||||
UI_DESIGN_DOC_ASSET_KIND, UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
};
|
||||
use crate::ui_editor::resource::font::FontAsset;
|
||||
use crate::ui_editor::resource::sprite::{SpriteAsset, SpriteAssetMetadata, SpriteBorder};
|
||||
@@ -684,8 +683,8 @@ fn find_page_ui_resource(
|
||||
let Some(asset) = matches.into_iter().next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if asset.kind != UI_DESIGN_DOC_ASSET_KIND
|
||||
|| asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE
|
||||
if asset.kind != "UI"
|
||||
|| asset.media_type != "application/json"
|
||||
|| asset.local_path != workflow_relative_path(source, &page.page_id)
|
||||
{
|
||||
return Err(format!("页面 {} 的 UI workflow 资源身份冲突", page.page_id));
|
||||
@@ -749,8 +748,8 @@ fn ensure_page_ui_resource(
|
||||
let registered = register_local_asset_at(
|
||||
root,
|
||||
&relative_path,
|
||||
UI_DESIGN_DOC_ASSET_KIND,
|
||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
"UI",
|
||||
"application/json",
|
||||
"ui-workflow",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
@@ -1180,7 +1179,7 @@ fn update_page_manifest_stage(root: &Path, asset_id: &str, stage: &str) -> Resul
|
||||
.iter_mut()
|
||||
.find(|asset| asset.id == asset_id)
|
||||
.ok_or_else(|| format!("UI workflow 资源 {} 未登记", asset_id))?;
|
||||
if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE {
|
||||
if asset.kind != "UI" || asset.media_type != "application/json" {
|
||||
return Err(format!("UI workflow 资源 {} 类型不匹配", asset_id));
|
||||
}
|
||||
let next_kind = format!("ui-workflow.{stage}");
|
||||
|
||||
+1
-5
@@ -53,7 +53,6 @@ import { createPortal, flushSync } from 'react-dom';
|
||||
import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar';
|
||||
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
|
||||
import {
|
||||
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
type GameCreationAppAssetManifestEntry,
|
||||
gameCreationAppAssetTags,
|
||||
type GameIterationVersion,
|
||||
@@ -1254,10 +1253,7 @@ function ResourcePickerThumbnail({
|
||||
if (mediaType.startsWith('image/')) {
|
||||
return <Loader2 size={18} className="animate-spin" aria-hidden="true" />;
|
||||
}
|
||||
if (
|
||||
kind === 'ui' ||
|
||||
mediaType === GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE
|
||||
) {
|
||||
if (kind === 'ui' || mediaType === 'application/json') {
|
||||
return <FileText size={18} aria-hidden="true" />;
|
||||
}
|
||||
return <ImageIcon size={18} aria-hidden="true" />;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Node as UiNode } from './types/Node';
|
||||
import type { NodeId } from './types/NodeId';
|
||||
import type { NodeMetadata } from './types/NodeMetadata';
|
||||
import type { StageStatus } from './types/StageStatus';
|
||||
import type { UIDesignImageId } from './types/UIDesignImageId';
|
||||
@@ -14,6 +15,11 @@ export type UiTreeNodeTarget = {
|
||||
node: UiNode;
|
||||
};
|
||||
|
||||
export type UiTreeNodeCursor = {
|
||||
treeId: UIDesignImageId;
|
||||
nodeId: NodeId;
|
||||
};
|
||||
|
||||
export type StageStatusOverview = {
|
||||
total: number;
|
||||
needsAttention: number;
|
||||
@@ -79,22 +85,28 @@ export function getStageStatusTargets(
|
||||
|
||||
export function getNextUiTreeNodeTarget(
|
||||
targets: UiTreeNodeTarget[],
|
||||
previousNodeId: string | null,
|
||||
previous: string | UiTreeNodeCursor | null,
|
||||
): UiTreeNodeTarget | null {
|
||||
if (targets.length === 0) return null;
|
||||
const previousIndex = targets.findIndex(
|
||||
({ node }) => node.id === previousNodeId,
|
||||
);
|
||||
const previousIndex =
|
||||
typeof previous === 'string'
|
||||
? targets.findIndex(({ node }) => node.id === previous)
|
||||
: previous
|
||||
? targets.findIndex(
|
||||
({ treeId, node }) =>
|
||||
treeId === previous.treeId && node.id === previous.nodeId,
|
||||
)
|
||||
: -1;
|
||||
return targets[(previousIndex + 1) % targets.length] ?? null;
|
||||
}
|
||||
|
||||
export function getNextMatchingUiTreeNodeTarget(
|
||||
uiTrees: UITree[],
|
||||
previousNodeId: string | null,
|
||||
previous: string | UiTreeNodeCursor | null,
|
||||
matches: (target: UiTreeNodeTarget) => boolean,
|
||||
): UiTreeNodeTarget | null {
|
||||
return getNextUiTreeNodeTarget(
|
||||
collectUiTreeNodeTargets(uiTrees).filter(matches),
|
||||
previousNodeId,
|
||||
previous,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ import type { Component } from "./Component";
|
||||
import type { ControlLayout } from "./ControlLayout";
|
||||
import type { NodeId } from "./NodeId";
|
||||
import type { NodeMetadata } from "./NodeMetadata";
|
||||
import type { NodeOffset } from "./NodeOffset";
|
||||
|
||||
export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array<Node>, };
|
||||
export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array<Node>, offset: NodeOffset };
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export type NodeOffset = {
|
||||
min: [number, number];
|
||||
max: [number, number];
|
||||
};
|
||||
@@ -2,10 +2,6 @@ import type {
|
||||
GameCreationAppAssetManifestEntry,
|
||||
GameCreationAppManifest,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
|
||||
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
|
||||
export type UiDesignResourceBridgeResult = {
|
||||
asset: GameCreationAppAssetManifestEntry;
|
||||
@@ -52,8 +48,8 @@ export function findLinkedUiDesignResource(
|
||||
manifest.assets
|
||||
.filter(
|
||||
(asset) =>
|
||||
asset.kind === GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND &&
|
||||
asset.mediaType === GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE &&
|
||||
asset.kind === 'UI' &&
|
||||
asset.mediaType === 'application/json' &&
|
||||
asset.source.referenceResourceIds?.some((reference) =>
|
||||
referenceIds.has(reference),
|
||||
),
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { FontAssetId } from './types/FontAssetId';
|
||||
import type { Node } from './types/Node';
|
||||
import type { NodeId } from './types/NodeId';
|
||||
import type { NodeMetadata } from './types/NodeMetadata';
|
||||
import type { NodeOffset } from './types/NodeOffset';
|
||||
import type { SpriteAsset } from './types/SpriteAsset';
|
||||
import type { SpriteAssetId } from './types/SpriteAssetId';
|
||||
import type { SpriteBorder } from './types/SpriteBorder';
|
||||
@@ -34,6 +35,7 @@ export const EMPTY_UI_EDITOR_STATE: State = {
|
||||
};
|
||||
|
||||
const MAX_HISTORY_LENGTH = 100;
|
||||
export const UI_TREE_PADDING = 48;
|
||||
|
||||
export type UiEditorOperationFailureReason =
|
||||
| 'locked'
|
||||
@@ -156,6 +158,7 @@ function createPageRoot(state: State): Node {
|
||||
component: null,
|
||||
children_display_mode: 'Stack',
|
||||
children: [],
|
||||
offset: { min: [0, 0], max: [0, 0] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -187,9 +190,53 @@ function createHumanNode(state: State): Node {
|
||||
component: null,
|
||||
children_display_mode: 'Stack',
|
||||
children: [],
|
||||
offset: { min: [0, 0], max: [0, 0] },
|
||||
};
|
||||
}
|
||||
|
||||
function treeSize(state: State, treeId: UIDesignImageId): [number, number] {
|
||||
const image = state.ui_design_images[treeId];
|
||||
if (
|
||||
!image ||
|
||||
!Number.isFinite(image.pixels_per_unit) ||
|
||||
image.pixels_per_unit <= 0
|
||||
) {
|
||||
throw new Error(`界面图 ${treeId} 缺少合法尺寸`);
|
||||
}
|
||||
return [
|
||||
image.pixel_size[0] / image.pixels_per_unit,
|
||||
image.pixel_size[1] / image.pixels_per_unit,
|
||||
];
|
||||
}
|
||||
|
||||
function deriveTreeOffset(state: State, treeId: UIDesignImageId): NodeOffset {
|
||||
const [width, height] = treeSize(state, treeId);
|
||||
const existing = state.ui_trees.filter(
|
||||
(tree) => tree.src_ui_design !== treeId,
|
||||
);
|
||||
if (existing.length === 0) return { min: [0, 0], max: [width, height] };
|
||||
const maxX = Math.max(
|
||||
...existing.map(
|
||||
(tree) =>
|
||||
tree.root.offset.min[0] + treeSize(state, tree.src_ui_design)[0],
|
||||
),
|
||||
);
|
||||
const minY = Math.min(...existing.map((tree) => tree.root.offset.min[1]));
|
||||
return {
|
||||
min: [maxX + UI_TREE_PADDING, minY],
|
||||
max: [maxX + UI_TREE_PADDING + width, minY + height],
|
||||
};
|
||||
}
|
||||
|
||||
export function createTree(
|
||||
state: State,
|
||||
treeId: UIDesignImageId,
|
||||
root = createPageRoot(state),
|
||||
) {
|
||||
root.offset = deriveTreeOffset(state, treeId);
|
||||
return { src_ui_design: treeId, root };
|
||||
}
|
||||
|
||||
function synchronizeDesignImageTrees(state: State): void {
|
||||
const imageIds = new Set(Object.keys(state.ui_design_images));
|
||||
state.ui_trees = state.ui_trees.filter((tree) =>
|
||||
@@ -197,7 +244,7 @@ function synchronizeDesignImageTrees(state: State): void {
|
||||
);
|
||||
for (const [id] of Object.entries(state.ui_design_images)) {
|
||||
if (!state.ui_trees.some((tree) => tree.src_ui_design === id)) {
|
||||
state.ui_trees.push({ src_ui_design: id, root: createPageRoot(state) });
|
||||
state.ui_trees.push(createTree(state, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -951,6 +998,34 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
[commit, guard],
|
||||
);
|
||||
|
||||
const setTreeOffset = useCallback(
|
||||
(
|
||||
treeId: UIDesignImageId,
|
||||
min: [number, number],
|
||||
): UiEditorOperationResult => {
|
||||
const blocked = guard();
|
||||
if (blocked) return blocked;
|
||||
if (!min.every(Number.isFinite)) return { ok: false, reason: 'invalid' };
|
||||
const current = stateRef.current;
|
||||
const tree = current.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
);
|
||||
if (!tree) return { ok: false, reason: 'missing' };
|
||||
const size = treeSize(current, treeId);
|
||||
const next = cloneState(current);
|
||||
const nextTree = next.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
)!;
|
||||
nextTree.root.offset = {
|
||||
min: [...min],
|
||||
max: [min[0] + size[0], min[1] + size[1]],
|
||||
};
|
||||
commit(next);
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
[commit, guard],
|
||||
);
|
||||
|
||||
const insertNodeAfter = useCallback(
|
||||
(
|
||||
treeId: UIDesignImageId,
|
||||
@@ -1464,6 +1539,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
setSpriteBorder,
|
||||
insertNode,
|
||||
insertNodeAfter,
|
||||
setTreeOffset,
|
||||
deleteNode,
|
||||
setNodeTransform,
|
||||
setNodeLayout,
|
||||
|
||||
@@ -73,10 +73,6 @@ import type {
|
||||
GameIterationVersion,
|
||||
ProjectResourceCanvasLayoutMode,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
|
||||
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { ImageCanvasCharacterAnimationPanelView } from '../../../../../src/components/image-editor/ImageCanvasCharacterAnimationPanelView';
|
||||
import type {
|
||||
CanvasLayer,
|
||||
@@ -4637,12 +4633,7 @@ export default function ProjectDevelopmentView({
|
||||
passive: false,
|
||||
});
|
||||
return () => manager.removeEventListener('wheel', handleResourceBookWheel);
|
||||
}, [
|
||||
handleResourceBookWheel,
|
||||
mode,
|
||||
// UI 编辑器会卸载整个资源 manager;返回时 ref 指向新节点,必须重新绑定原生 wheel。
|
||||
uiEditorRoute,
|
||||
]);
|
||||
}, [handleResourceBookWheel, mode]);
|
||||
|
||||
const handleResourceBookMainPointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
@@ -5044,8 +5035,8 @@ export default function ProjectDevelopmentView({
|
||||
if (canvasOpenEpochRef.current !== openEpoch) return;
|
||||
if (
|
||||
result.manifest.projectId !== manifest.projectId ||
|
||||
result.asset.kind !== GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND ||
|
||||
result.asset.mediaType !== GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE
|
||||
result.asset.kind !== 'UI' ||
|
||||
result.asset.mediaType !== 'application/json'
|
||||
) {
|
||||
throw new Error('UI 编辑资源结果与当前项目不一致');
|
||||
}
|
||||
@@ -5116,8 +5107,8 @@ export default function ProjectDevelopmentView({
|
||||
if (uiEditorRoute) return;
|
||||
const completed = manifest.assets.find(
|
||||
(asset) =>
|
||||
asset.kind === GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND &&
|
||||
asset.mediaType === GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE &&
|
||||
asset.kind === 'UI' &&
|
||||
asset.mediaType === 'application/json' &&
|
||||
asset.source.generationKind === 'ui-workflow.completed',
|
||||
);
|
||||
if (
|
||||
@@ -6816,7 +6807,7 @@ export default function ProjectDevelopmentView({
|
||||
[manifest, selectedResource],
|
||||
);
|
||||
const selectedResourceOpensUiEditor =
|
||||
selectedResource?.subtype === GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND ||
|
||||
selectedResource?.subtype === 'UI' ||
|
||||
selectedResource?.subtype === 'ui-prototype';
|
||||
|
||||
const selectedToolbarStyle = selectedResourceLayer
|
||||
|
||||
@@ -70,6 +70,7 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
|
||||
component: null,
|
||||
children_display_mode: 'Stack',
|
||||
children: uiTrees.map((tree) => tree.root),
|
||||
offset: { min: [0, 0], max: [0, 0] },
|
||||
};
|
||||
}, [uiTrees]);
|
||||
|
||||
@@ -83,8 +84,14 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
|
||||
treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null}
|
||||
isNodePreviewVisible={input.isNodePreviewVisible}
|
||||
onSelectNode={(treeId, nodeId) => {
|
||||
input.selectDesignImage(treeId);
|
||||
input.selectNode(nodeId);
|
||||
// react-arborist emits `onSelect` when its controlled `selection`
|
||||
// prop is updated. Overview navigation updates the selection and
|
||||
// the status highlight in the same render, so treating that
|
||||
// programmatic notification as a fresh user selection would clear
|
||||
// the highlight before it can be painted. Only mutate selection
|
||||
// state when the target actually differs from the current one.
|
||||
const sameNode = input.selectedNodeId === nodeId;
|
||||
if (!sameNode) input.selectNode(nodeId);
|
||||
}}
|
||||
onToggleNodeVisibility={(nodeId) =>
|
||||
input.toggleNodePreviewVisibility(nodeId)
|
||||
|
||||
+24
-8
@@ -284,7 +284,7 @@ function NodeInspector({
|
||||
onMetadataChange,
|
||||
highlightedStatusField,
|
||||
onTransformChange,
|
||||
onLayoutChange,
|
||||
// onLayoutChange,
|
||||
sprites,
|
||||
previewUrls,
|
||||
fonts,
|
||||
@@ -483,11 +483,11 @@ function NodeInspector({
|
||||
readOnly={transformReadOnly || isReadOnly}
|
||||
onChange={onTransformChange}
|
||||
/>
|
||||
<LayoutEditor
|
||||
node={node}
|
||||
readOnly={isReadOnly}
|
||||
onChange={onLayoutChange}
|
||||
/>
|
||||
{/*<LayoutEditor*/}
|
||||
{/* node={node}*/}
|
||||
{/* readOnly={isReadOnly}*/}
|
||||
{/* onChange={onLayoutChange}*/}
|
||||
{/*/>*/}
|
||||
<ComponentPanel
|
||||
key={node.id}
|
||||
component={node.component}
|
||||
@@ -511,6 +511,7 @@ function NodeInspector({
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
function LayoutEditor({
|
||||
node,
|
||||
readOnly,
|
||||
@@ -826,11 +827,26 @@ function NodeStageSelect({
|
||||
const attentionTone = kind === 'Blocked' ? 'blocked' : 'review';
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlight) return;
|
||||
statusRowRef.current?.scrollIntoView({
|
||||
const statusRow = statusRowRef.current;
|
||||
if (!highlight || !statusRow) return;
|
||||
statusRow.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
|
||||
// 强制制造一次样式边界,避免 A → B → A 时浏览器复用已完成的动画。
|
||||
statusRow.classList.remove('ui-editor-status-attention');
|
||||
void statusRow.offsetWidth;
|
||||
statusRow.classList.add('ui-editor-status-attention');
|
||||
|
||||
// 多节点切换会复用 Inspector 树,显式重启动画,确保回到已查看节点时
|
||||
// 仍能再次播放提示,而不是依赖 class/key 的重协调行为。
|
||||
if (typeof statusRow.getAnimations === 'function') {
|
||||
for (const animation of statusRow.getAnimations()) {
|
||||
animation.cancel();
|
||||
animation.play();
|
||||
}
|
||||
}
|
||||
}, [highlight]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -44,7 +44,7 @@ export function RecognitionOverview({
|
||||
Overview
|
||||
</span>
|
||||
<h2 className="m-0 text-sm font-semibold">识别概览</h2>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div className="mt-3 grid grid-cols-4 gap-2">
|
||||
<OverviewValue label="已识别 Node" value={overview.total} />
|
||||
<OverviewAction
|
||||
label="待用户检查"
|
||||
|
||||
@@ -47,7 +47,7 @@ export function SeparationOverview({
|
||||
Overview
|
||||
</span>
|
||||
<h2 className="m-0 text-sm font-semibold">自动切分素材概览</h2>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div className="mt-3 grid grid-cols-4 gap-2">
|
||||
<OverviewValue
|
||||
label="需要切分素材的组件"
|
||||
value={overview.componentsNeedingAssets}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function UiEditorCopyPathButton({
|
||||
relativePath,
|
||||
}: {
|
||||
relativePath: string;
|
||||
}) {
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
|
||||
useEffect(() => setCopyState('idle'), [relativePath]);
|
||||
|
||||
async function copyPath() {
|
||||
setCopyState('idle');
|
||||
try {
|
||||
await writeText(relativePath);
|
||||
setCopyState('copied');
|
||||
} catch (error) {
|
||||
setCopyState('failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{copyState === 'failed' ? (
|
||||
<p className="mt-2 text-xs text-red-600" role="alert">
|
||||
复制失败,请手动复制路径。
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
|
||||
onClick={() => void copyPath()}
|
||||
>
|
||||
<Copy size={14} aria-hidden="true" />
|
||||
{copyState === 'copied' ? '已复制' : '复制路径'}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user