diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs index 206e545c7..5810c5298 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs @@ -269,16 +269,19 @@ mod materialize { .map(|member| build_node(member, records, used_original_ids, occupied_ids)) .collect::, _>>()?; let oldest_index = oldest_member_index(&members)?; - let original_transform = members[oldest_index].node.transform; + let original_transform = members[oldest_index].node.layout.transform; let priority = members[oldest_index].priority; let src_ui_design = members[oldest_index].src_ui_design.clone(); for member in &mut members { - member.node.transform = Transform::stretch(); + member.node.layout.transform = Transform::stretch(); } Ok(BuiltNode { node: LayoutNode { id: random_node_id(occupied_ids)?, - transform: original_transform, + layout: + crate::ui_editor::layout::control_layout::ControlLayout::with_transform( + original_transform, + ), metadata: NodeMetadata { name: container_name, description: container_description, @@ -394,6 +397,7 @@ mod tests { use super::materialize; 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::node::{Node, NodeMetadata, NodeSource, StageStatus}; use crate::ui_editor::layout::transform::Transform; use crate::ui_editor::state::{State, UITree}; @@ -403,7 +407,7 @@ mod tests { fn node(id: &str, children: Vec) -> Node { Node { id: NodeId::new(id).expect("valid node id"), - transform: Transform::stretch(), + layout: ControlLayout::with_transform(Transform::stretch()), metadata: NodeMetadata { name: id.to_string(), description: String::new(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs index 0deabad40..e143ab9c1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs @@ -1,6 +1,7 @@ use crate::config::build_game_creator_llm_client_from_config; use crate::ui_editor::commands::utils::strict_json_schema; 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::transform::Transform; @@ -269,7 +270,7 @@ fn convert_node( .collect::, _>>()?; Ok(LayoutNode { id: random_node_id()?, - transform, + layout: ControlLayout::with_transform(transform), metadata: NodeMetadata { name: source.name.clone(), description: source.description.clone(), @@ -433,7 +434,7 @@ mod tests { convert_node(&test_node(), &image_id, &image, root_rect).expect("node conversion"); assert_rect_close( - converted.transform.resolve(&root_rect), + converted.layout.transform.resolve(&root_rect), UIRect::new(Point2::new(50.0, 25.0), Vector2::new(100.0, 50.0)), ); assert_eq!(converted.metadata.components_status, StageStatus::Passed); @@ -642,7 +643,7 @@ pub(crate) async fn recognize_ui_impl( .collect::, _>>()?; let root = LayoutNode { id: random_node_id()?, - transform: Transform::stretch(), + layout: ControlLayout::with_transform(Transform::stretch()), metadata: NodeMetadata { name: "页面根节点".to_string(), description: String::new(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/control_layout.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/control_layout.rs new file mode 100644 index 000000000..bf7e69c27 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/control_layout.rs @@ -0,0 +1,115 @@ +use crate::ui_editor::layout::transform::Transform; +use nalgebra::Vector2; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// Godot Control 的 Container 相关布局属性。 +/// +/// `Transform` 始终被保留。只有直接父节点为 Container 时,父节点才会 +/// 忽略 child 的 anchors / offsets,改读 minimum size 与 size flags。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct ControlLayout { + // TODO let llm output container layout + pub transform: Transform, + #[ts(as = "[f32; 2]")] + pub custom_minimum_size: Vector2, + #[ts(as = "u8")] + pub size_flags_horizontal: u8, + #[ts(as = "u8")] + pub size_flags_vertical: u8, + pub size_flags_stretch_ratio: f32, + pub container: Container, +} + +impl Default for ControlLayout { + fn default() -> Self { + Self { + transform: Transform::default(), + custom_minimum_size: Vector2::zeros(), + size_flags_horizontal: SIZE_FLAG_FILL, + size_flags_vertical: SIZE_FLAG_FILL, + size_flags_stretch_ratio: 1.0, + container: Container::None, + } + } +} + +impl ControlLayout { + pub fn with_transform(transform: Transform) -> Self { + Self { + transform, + ..Self::default() + } + } +} + +/// Godot Control size flags 的有效 bitmask 值。 +/// +/// JSON 中使用 Godot 兼容数值:0/1/2/3/4/8。 +pub const SIZE_FLAG_FILL: u8 = 1; + +pub const fn is_valid_size_flag(value: u8) -> bool { + matches!(value, 0 | 1 | 2 | 3 | 4 | 8) +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub enum ContainerAlignment { + Begin, + Center, + End, +} + +/// 互斥 Container 定义。没有 Container 即普通 Control。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub enum Container { + None, + HBox { + alignment: ContainerAlignment, + separation: f32, + }, + VBox { + alignment: ContainerAlignment, + separation: f32, + }, + Grid { + columns: u32, + h_separation: f32, + v_separation: f32, + }, + Margin { + margin_left: f32, + margin_top: f32, + margin_right: f32, + margin_bottom: f32, + }, + Center { + use_top_left: bool, + }, +} + +impl Container { + pub const fn hbox() -> Self { + Self::HBox { + alignment: ContainerAlignment::Begin, + separation: 4.0, + } + } + + pub const fn vbox() -> Self { + Self::VBox { + alignment: ContainerAlignment::Begin, + separation: 4.0, + } + } + + pub const fn grid() -> Self { + Self::Grid { + columns: 1, + h_separation: 4.0, + v_separation: 4.0, + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs index 1a7e4f471..b0932dc49 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs @@ -1,4 +1,5 @@ pub mod children_display_mode; +pub mod control_layout; pub mod dimension; pub mod node; pub mod transform; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs index 3779b49aa..dbddf62cd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs @@ -1,6 +1,6 @@ use crate::ui_editor::component::Component; use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; -use crate::ui_editor::layout::transform::Transform; +use crate::ui_editor::layout::control_layout::ControlLayout; use crate::ui_editor::utils::NodeId; use serde::{Deserialize, Serialize}; use ts_rs::TS; @@ -9,7 +9,7 @@ use ts_rs::TS; #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub struct Node { pub id: NodeId, - pub transform: Transform, + pub layout: ControlLayout, pub metadata: NodeMetadata, pub components: Vec, pub children_display_mode: ChildrenDisplayMode, 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 36bb8166e..0d3d5df44 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,5 @@ -use crate::ui_editor::component::Component; use crate::ui_editor::component::text::FontSource; +use crate::ui_editor::component::Component; use crate::ui_editor::layout::node::Node; use crate::ui_editor::state::State; use crate::*; @@ -505,21 +505,25 @@ fn validate_node( return Err("UI 设计节点 ID 不能重复".to_string()); } if !node + .layout .transform .anchor_min .iter() .all(|value| value.is_finite()) || !node + .layout .transform .anchor_max .iter() .all(|value| value.is_finite()) || !node + .layout .transform .offset_min .iter() .all(|value| value.is_finite()) || !node + .layout .transform .offset_max .iter() @@ -527,6 +531,65 @@ fn validate_node( { return Err("UI 节点 Transform 必须是有限数值".to_string()); } + if !node + .layout + .custom_minimum_size + .iter() + .all(|value| value.is_finite() && *value >= 0.0) + { + return Err("UI 节点 custom_minimum_size 必须是有限非负数".to_string()); + } + if !node.layout.size_flags_stretch_ratio.is_finite() + || node.layout.size_flags_stretch_ratio <= 0.0 + { + return Err("UI 节点 size_flags_stretch_ratio 必须是有限正数".to_string()); + } + if !crate::ui_editor::layout::control_layout::is_valid_size_flag( + node.layout.size_flags_horizontal, + ) || !crate::ui_editor::layout::control_layout::is_valid_size_flag( + node.layout.size_flags_vertical, + ) { + return Err("UI 节点 size flags 无效".to_string()); + } + match &node.layout.container { + crate::ui_editor::layout::control_layout::Container::None + | crate::ui_editor::layout::control_layout::Container::Center { .. } => {} + crate::ui_editor::layout::control_layout::Container::HBox { separation, .. } + | crate::ui_editor::layout::control_layout::Container::VBox { separation, .. } => { + if !separation.is_finite() || *separation < 0.0 { + return Err("Container separation 必须是有限非负数".to_string()); + } + } + crate::ui_editor::layout::control_layout::Container::Grid { + columns, + h_separation, + v_separation, + } => { + if *columns == 0 { + return Err("GridContainer columns 必须大于 0".to_string()); + } + if !h_separation.is_finite() + || *h_separation < 0.0 + || !v_separation.is_finite() + || *v_separation < 0.0 + { + return Err("GridContainer separation 必须是有限非负数".to_string()); + } + } + crate::ui_editor::layout::control_layout::Container::Margin { + margin_left, + margin_top, + margin_right, + margin_bottom, + } => { + if ![*margin_left, *margin_top, *margin_right, *margin_bottom] + .iter() + .all(|value| value.is_finite() && *value >= 0.0) + { + return Err("MarginContainer 边距必须是有限非负数".to_string()); + } + } + } if node.components.len() > UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE { return Err(format!( "单个 UI 节点最多支持 {UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE} 个组件" diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/ChildrenDisplayMode.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/ChildrenDisplayMode.ts index 7ed4b3362..a9aaf69ca 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/types/ChildrenDisplayMode.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/ChildrenDisplayMode.ts @@ -1,2 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + export type ChildrenDisplayMode = 'Stack' | 'Exclusive'; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/Container.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/Container.ts new file mode 100644 index 000000000..59a9b0b3d --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/Container.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ContainerAlignment } from './ContainerAlignment'; + +/** + * 互斥 Container 定义。没有 Container 即普通 Control。 + */ +export type Container = + | 'None' + | { HBox: { alignment: ContainerAlignment; separation: number } } + | { VBox: { alignment: ContainerAlignment; separation: number } } + | { Grid: { columns: number; h_separation: number; v_separation: number } } + | { + Margin: { + margin_left: number; + margin_top: number; + margin_right: number; + margin_bottom: number; + }; + } + | { Center: { use_top_left: boolean } }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/ContainerAlignment.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/ContainerAlignment.ts new file mode 100644 index 000000000..bfb465b5e --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/ContainerAlignment.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ContainerAlignment = 'Begin' | 'Center' | 'End'; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/ControlLayout.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/ControlLayout.ts new file mode 100644 index 000000000..aa68c4aa5 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/ControlLayout.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Container } from './Container'; +import type { Transform } from './Transform'; + +/** + * Godot Control 的 Container 相关布局属性。 + * + * `Transform` 始终被保留。只有直接父节点为 Container 时,父节点才会 + * 忽略 child 的 anchors / offsets,改读 minimum size 与 size flags。 + */ +export type ControlLayout = { + transform: Transform; + custom_minimum_size: [number, number]; + size_flags_horizontal: number; + size_flags_vertical: number; + size_flags_stretch_ratio: number; + container: Container; +}; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts index 6677e4733..c014b35ad 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts @@ -1,13 +1,13 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ChildrenDisplayMode } from './ChildrenDisplayMode'; import type { Component } from './Component'; +import type { ControlLayout } from './ControlLayout'; import type { NodeId } from './NodeId'; import type { NodeMetadata } from './NodeMetadata'; -import type { Transform } from './Transform'; export type Node = { id: NodeId; - transform: Transform; + layout: ControlLayout; metadata: NodeMetadata; components: Array; children_display_mode: ChildrenDisplayMode; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index e9034474a..9e62335d4 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -56,6 +56,8 @@ export type NodeTransformOptions = { keepChildrenUnchanged?: boolean; }; +export type NodeLayoutPatch = Partial; + type Rect = { min: [number, number]; size: [number, number]; @@ -93,11 +95,18 @@ function createUniqueNodeId(state: State): NodeId { function createPageRoot(state: State): Node { return { id: createUniqueNodeId(state), - transform: { - anchor_min: [0, 0], - anchor_max: [1, 1], - offset_min: [0, 0], - offset_max: [0, 0], + layout: { + transform: { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], + }, + custom_minimum_size: [0, 0], + size_flags_horizontal: 1, + size_flags_vertical: 1, + size_flags_stretch_ratio: 1, + container: 'None', }, metadata: { name: '页面根节点', @@ -117,11 +126,18 @@ function createPageRoot(state: State): Node { function createHumanNode(state: State): Node { return { id: createUniqueNodeId(state), - transform: { - anchor_min: [0.5, 0.5], - anchor_max: [0.5, 0.5], - offset_min: [-50, -50], - offset_max: [50, 50], + layout: { + transform: { + anchor_min: [0.5, 0.5], + anchor_max: [0.5, 0.5], + offset_min: [-50, -50], + offset_max: [50, 50], + }, + custom_minimum_size: [0, 0], + size_flags_horizontal: 1, + size_flags_vertical: 1, + size_flags_stretch_ratio: 1, + container: 'None', }, metadata: { name: '新节点', @@ -150,7 +166,10 @@ function synchronizeDesignImageTrees(state: State): void { } } -function resolveNodeRect(transform: Node['transform'], parent: Rect): Rect { +function resolveNodeRect( + transform: Node['layout']['transform'], + parent: Rect, +): Rect { const min: [number, number] = [ parent.min[0] + parent.size[0] * transform.anchor_min[0] + @@ -198,7 +217,7 @@ function findNodePageRect( id: NodeId, parentRect: Rect, ): Rect | null { - const rect = resolveNodeRect(node.transform, parentRect); + const rect = resolveNodeRect(node.layout.transform, parentRect); if (node.id === id) return rect; for (const child of node.children) { const found = findNodePageRect(child, id, rect); @@ -212,10 +231,10 @@ function containsNode(root: Node, id: NodeId): boolean { } function setOffsetsForPageRect( - transform: Node['transform'], + transform: Node['layout']['transform'], pageRect: Rect, parentPageRect: Rect, -): Node['transform'] | null { +): Node['layout']['transform'] | null { if (!isValidRect(pageRect) || !isValidRect(parentPageRect)) return null; const next = structuredClone(transform); next.offset_min = [ @@ -895,7 +914,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { ( treeId: UIDesignImageId, nodeId: NodeId, - transform: Node['transform'], + transform: Node['layout']['transform'], options: NodeTransformOptions = {}, ): UiEditorOperationResult => { const blocked = guard(); @@ -925,7 +944,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { (candidate) => candidate.src_ui_design === treeId, )!; const nextNode = findNodeLocation(nextTree.root, nodeId)!.node; - nextNode.transform = structuredClone(transform); + nextNode.layout.transform = structuredClone(transform); if (options.keepChildrenUnchanged && location.parent) { const image = current.ui_design_images[treeId]; if ( @@ -961,11 +980,14 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { return { ok: false, reason: 'invalid' }; } const childTransforms = location.node.children.map((child) => { - const childRect = resolveNodeRect(child.transform, oldNodeRect); + const childRect = resolveNodeRect( + child.layout.transform, + oldNodeRect, + ); return { id: child.id, transform: setOffsetsForPageRect( - child.transform, + child.layout.transform, childRect, newNodeRect, ), @@ -981,7 +1003,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { if (!nextChild || !child.transform) { return { ok: false, reason: 'invalid' }; } - nextChild.transform = child.transform; + nextChild.layout.transform = child.transform; } } commit(next); @@ -990,6 +1012,45 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { [commit, guard], ); + const setNodeLayout = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + patch: NodeLayoutPatch, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree || !findNodeLocation(tree.root, nodeId)) + return { ok: false, reason: 'missing' }; + const next = cloneState(current); + const node = findNodeLocation( + next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)! + .root, + nodeId, + )!.node; + const layout = { ...node.layout, ...structuredClone(patch) }; + if ( + !layout.custom_minimum_size.every( + (value) => Number.isFinite(value) && value >= 0, + ) || + !Number.isFinite(layout.size_flags_stretch_ratio) || + layout.size_flags_stretch_ratio <= 0 || + ![0, 1, 2, 3, 4, 8].includes(layout.size_flags_horizontal) || + ![0, 1, 2, 3, 4, 8].includes(layout.size_flags_vertical) + ) { + return { ok: false, reason: 'invalid' }; + } + node.layout = layout; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + const setNodeComponents = useCallback( ( treeId: UIDesignImageId, @@ -1244,7 +1305,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { ) { return { ok: false, reason: 'invalid' }; } - let nextTransform: Node['transform'] | null = null; + let nextTransform: Node['layout']['transform'] | null = null; if (sourceTreeId === targetTreeId) { const image = current.ui_design_images[sourceTreeId]; if (!image) return { ok: false, reason: 'missing' }; @@ -1276,7 +1337,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { return { ok: false, reason: 'invalid' }; } nextTransform = setOffsetsForPageRect( - source.node.transform, + source.node.layout.transform, pageRect, targetPageRect, ); @@ -1303,7 +1364,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { if (!moved) return { ok: false, reason: 'invalid' }; // 跨树时只保留节点原本的局部 transform;源、目标界面图的画布和父级坐标空间独立, // “保留 transform”不等于承诺页面位置稳定。只有同树移动才在上面做页面矩形换算。 - if (nextTransform) moved.transform = nextTransform; + if (nextTransform) moved.layout.transform = nextTransform; nextTarget.children.splice(targetIndex, 0, moved); commit(next); return { ok: true, value: undefined }; @@ -1427,6 +1488,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { insertNodeAfter, deleteNode, setNodeTransform, + setNodeLayout, setNodeComponents, insertComponent, deleteComponent, diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/utils/layout/controlLayoutToCss.ts b/apps/ai-game-creator-shell/src/features/ui-editor/utils/layout/controlLayoutToCss.ts new file mode 100644 index 000000000..37ef4e568 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/utils/layout/controlLayoutToCss.ts @@ -0,0 +1,147 @@ +import type { CSSProperties } from 'react'; + +import type { Container } from '../../types/Container'; +import type { ControlLayout } from '../../types/ControlLayout'; +import { tf2css } from '../transform/tf2css'; + +type Axis = 'horizontal' | 'vertical'; + +function itemAxisStyle( + layout: ControlLayout, + axis: Axis, +): Pick { + const minimum = layout.custom_minimum_size[axis === 'horizontal' ? 0 : 1]; + const flags = + axis === 'horizontal' + ? layout.size_flags_horizontal + : layout.size_flags_vertical; + const style: Pick< + CSSProperties, + 'minWidth' | 'minHeight' | 'flexGrow' | 'alignSelf' + > = axis === 'horizontal' ? { minWidth: minimum } : { minHeight: minimum }; + if ((flags & 2) !== 0) style.flexGrow = layout.size_flags_stretch_ratio; + return style; +} + +function crossAxisAlignment(flags: number): CSSProperties['alignSelf'] { + if ((flags & 1) !== 0) return 'stretch'; + if (flags === 4) return 'center'; + if (flags === 8) return 'flex-end'; + return 'flex-start'; +} + +function alignment( + value: 'Begin' | 'Center' | 'End', +): CSSProperties['justifyContent'] { + return value === 'Center' + ? 'center' + : value === 'End' + ? 'flex-end' + : 'flex-start'; +} + +/** + * 将持久化的 Godot Control/Container 数据单向翻译为 Preview CSS。 + * CSS 只是 Preview 后端,不反向定义或写回布局数据。 + */ +export function controlLayoutToPreviewCss( + layout: ControlLayout, + parentIsContainer: boolean, +): CSSProperties { + if (!parentIsContainer) return tf2css(layout.transform); + + const horizontal = itemAxisStyle(layout, 'horizontal'); + const vertical = itemAxisStyle(layout, 'vertical'); + return { + position: 'relative', + ...horizontal, + ...vertical, + }; +} + +export function containerToPreviewCss(container: Container): CSSProperties { + if (container === 'None') return {}; + if ( + typeof container === 'object' && + ('HBox' in container || 'VBox' in container) + ) { + const isHBox = 'HBox' in container; + const props = isHBox ? container.HBox : container.VBox; + return { + display: 'flex', + flexDirection: isHBox ? 'row' : 'column', + justifyContent: alignment(props.alignment), + gap: props.separation, + minWidth: 0, + minHeight: 0, + }; + } + if (typeof container === 'object' && 'Grid' in container) { + const props = container.Grid; + return { + display: 'grid', + gridTemplateColumns: `repeat(${props.columns}, minmax(0, 1fr))`, + columnGap: props.h_separation, + rowGap: props.v_separation, + minWidth: 0, + minHeight: 0, + }; + } + if (typeof container === 'object' && 'Margin' in container) { + const props = container.Margin; + return { + display: 'grid', + paddingLeft: props.margin_left, + paddingTop: props.margin_top, + paddingRight: props.margin_right, + paddingBottom: props.margin_bottom, + minWidth: 0, + minHeight: 0, + }; + } + return { + display: 'grid', + placeItems: 'center', + minWidth: 0, + minHeight: 0, + }; +} + +export function childInContainerToPreviewCss( + layout: ControlLayout, + parent: Container, +): CSSProperties { + const horizontal = itemAxisStyle(layout, 'horizontal'); + const vertical = itemAxisStyle(layout, 'vertical'); + if (typeof parent === 'object' && 'HBox' in parent) { + return { + ...horizontal, + ...vertical, + alignSelf: crossAxisAlignment(layout.size_flags_vertical), + }; + } + if (typeof parent === 'object' && 'VBox' in parent) { + return { + ...horizontal, + ...vertical, + alignSelf: crossAxisAlignment(layout.size_flags_horizontal), + }; + } + if (typeof parent === 'object' && 'Margin' in parent) + return { gridArea: '1 / 1', alignSelf: 'stretch', justifySelf: 'stretch' }; + if (typeof parent === 'object' && 'Center' in parent) { + const useTopLeft = parent.Center.use_top_left; + return useTopLeft + ? { + justifySelf: 'center', + alignSelf: 'center', + transform: 'translate(50%, 50%)', + } + : { justifySelf: 'center', alignSelf: 'center' }; + } + return { ...horizontal, ...vertical }; +} + +export function isContainer(container: Container): boolean { + return container !== 'None'; +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx index 260942d41..34445f15a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx @@ -34,6 +34,7 @@ type InspectorView = kind: 'node'; node: SelectedNode; parentSize: UiEditorPageController['selectedNodeParentSize']; + parentIsContainer: boolean; } | { kind: 'sprite'; @@ -83,6 +84,7 @@ export function InspectorSidebar({ @@ -97,6 +99,7 @@ export function InspectorSidebar({ onKeepChildrenUnchangedChange={controller.setKeepChildrenUnchanged} onMetadataChange={controller.setNodeMetadata} onTransformChange={controller.setNodeTransform} + onLayoutChange={controller.setNodeLayout} sprites={controller.sprites} fonts={controller.editor.state.font_assets} fontFaces={controller.fontFaces} @@ -105,11 +108,6 @@ export function InspectorSidebar({ onInsertComponent={controller.insertNodeComponent} onDeleteComponent={controller.deleteNodeComponent} onMoveComponent={controller.moveNodeComponent} - onDeleteNode={() => controller.deleteNode(view.node.id)} - deleteDisabled={ - controller.editor.isLocked || - view.node.id === controller.treeForActiveImage?.root.id - } /> ); break; @@ -209,6 +207,7 @@ function getInspectorView(controller: UiEditorPageController): InspectorView { selectedFontId, selectedNode, selectedNodeParentSize, + selectedNodeParent, previewUrls, spriteReferenceCounts, fontReferenceCounts, @@ -221,6 +220,7 @@ function getInspectorView(controller: UiEditorPageController): InspectorView { kind: 'node', node: selectedNode, parentSize: selectedNodeParentSize, + parentIsContainer: selectedNodeParent?.layout.container !== 'None', }; } @@ -259,6 +259,7 @@ function getInspectorView(controller: UiEditorPageController): InspectorView { function NodeInspector({ node, parentSize, + parentIsContainer, readOnly, isNodePreviewVisible, onSetNodePreviewVisible, @@ -268,6 +269,7 @@ function NodeInspector({ onKeepChildrenUnchangedChange, onMetadataChange, onTransformChange, + onLayoutChange, sprites, fonts, fontFaces, @@ -276,11 +278,10 @@ function NodeInspector({ onInsertComponent, onDeleteComponent, onMoveComponent, - onDeleteNode, - deleteDisabled, }: { node: SelectedNode; parentSize: UiEditorPageController['selectedNodeParentSize']; + parentIsContainer: boolean; readOnly: boolean; isNodePreviewVisible: boolean; onSetNodePreviewVisible: (visible: boolean) => void; @@ -290,6 +291,7 @@ function NodeInspector({ onKeepChildrenUnchangedChange: (value: boolean) => void; onMetadataChange: UiEditorPageController['setNodeMetadata']; onTransformChange: UiEditorPageController['setNodeTransform']; + onLayoutChange: UiEditorPageController['setNodeLayout']; sprites: UiEditorPageController['sprites']; fonts: UiEditorPageController['editor']['state']['font_assets']; fontFaces: UiEditorPageController['fontFaces']; @@ -298,8 +300,6 @@ function NodeInspector({ onInsertComponent: UiEditorPageController['insertNodeComponent']; onDeleteComponent: UiEditorPageController['deleteNodeComponent']; onMoveComponent: UiEditorPageController['moveNodeComponent']; - onDeleteNode: () => void; - deleteDisabled: boolean; }) { const inspectorReadOnly = useInspectorReadOnly(); const isReadOnly = readOnly || inspectorReadOnly; @@ -439,11 +439,21 @@ function NodeInspector({ + {parentIsContainer ? ( +

+ 父 Container 管理此节点;Transform 已保留,但 Preview 会忽略它。 +

+ ) : null} + - { - if (!deleteDisabled && !isReadOnly) onDeleteNode(); - }} - /> ); } +function LayoutEditor({ + node, + readOnly, + onChange, +}: { + node: SelectedNode; + readOnly: boolean; + onChange: UiEditorPageController['setNodeLayout']; +}) { + const layout = node.layout; + const containerKind = + layout.container === 'None' ? 'None' : Object.keys(layout.container)[0]!; + const hbox = + typeof layout.container === 'object' && 'HBox' in layout.container + ? layout.container.HBox + : null; + const vbox = + typeof layout.container === 'object' && 'VBox' in layout.container + ? layout.container.VBox + : null; + const grid = + typeof layout.container === 'object' && 'Grid' in layout.container + ? layout.container.Grid + : null; + const margin = + typeof layout.container === 'object' && 'Margin' in layout.container + ? layout.container.Margin + : null; + const center = + typeof layout.container === 'object' && 'Center' in layout.container + ? layout.container.Center + : null; + const updateNumber = (field: 'size_flags_stretch_ratio', value: string) => { + const parsed = Number(value); + if (Number.isFinite(parsed)) onChange({ [field]: parsed }); + }; + const updateMinimum = (axis: 0 | 1, value: string) => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return; + const next = [...layout.custom_minimum_size] as [number, number]; + next[axis] = parsed; + onChange({ custom_minimum_size: next }); + }; + const setContainer = (kind: string) => { + const container = + kind === 'HBox' + ? { HBox: { alignment: 'Begin' as const, separation: 4 } } + : kind === 'VBox' + ? { VBox: { alignment: 'Begin' as const, separation: 4 } } + : kind === 'Grid' + ? { Grid: { columns: 1, h_separation: 4, v_separation: 4 } } + : kind === 'Margin' + ? { + Margin: { + margin_left: 0, + margin_top: 0, + margin_right: 0, + margin_bottom: 0, + }, + } + : kind === 'Center' + ? { Center: { use_top_left: false } } + : 'None'; + onChange({ container }); + }; + const updateContainerNumber = (field: string, value: string) => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || layout.container === 'None') return; + const kind = Object.keys(layout.container)[0]!; + const current = layout.container as Record>; + onChange({ + container: { + [kind]: { ...current[kind], [field]: parsed }, + } as SelectedNode['layout']['container'], + }); + }; + + return ( +
+

Control 与 Container

+
+ updateMinimum(0, event.target.value)} + /> + updateMinimum(1, event.target.value)} + /> +
+
+ + onChange({ size_flags_horizontal: Number(event.target.value) }) + } + > + + + + onChange({ size_flags_vertical: Number(event.target.value) }) + } + > + + +
+ + updateNumber('size_flags_stretch_ratio', event.target.value) + } + /> + setContainer(event.target.value)} + > + + + + + + + + {hbox || vbox + ? (() => { + const props = hbox ?? vbox!; + return ( +
+ + onChange({ + container: hbox + ? { + HBox: { + ...props, + alignment: event.target.value as + 'Begin' | 'Center' | 'End', + }, + } + : { + VBox: { + ...props, + alignment: event.target.value as + 'Begin' | 'Center' | 'End', + }, + }, + }) + } + > + + + + + + updateContainerNumber('separation', event.target.value) + } + /> +
+ ); + })() + : null} + {grid ? ( +
+ + updateContainerNumber('columns', event.target.value) + } + /> + + updateContainerNumber('h_separation', event.target.value) + } + /> + + updateContainerNumber('v_separation', event.target.value) + } + /> +
+ ) : null} + {margin ? ( +
+ {( + [ + 'margin_left', + 'margin_top', + 'margin_right', + 'margin_bottom', + ] as const + ).map((field) => ( + + updateContainerNumber(field, event.target.value) + } + /> + ))} +
+ ) : null} + {center ? ( + + ) : null} +
+ ); +} + +function SizeFlagOptions() { + return ( + <> + + + + + + + + ); +} + function NodeStageSelect({ label, value, diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx index dac4df351..68431499d 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx @@ -3,7 +3,12 @@ import type { PointerEvent as ReactPointerEvent } from 'react'; import type { Node as UiNode } from '../../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../../features/ui-editor/types/NodeId'; import type { UITree } from '../../../../features/ui-editor/types/UITree'; -import { tf2css } from '../../../../features/ui-editor/utils/transform/tf2css'; +import { + childInContainerToPreviewCss, + containerToPreviewCss, + controlLayoutToPreviewCss, + isContainer, +} from '../../../../features/ui-editor/utils/layout/controlLayoutToCss'; import { ComponentView } from './components/ComponentView'; import type { PreviewComponentResources } from './components/types'; import type { ResizeHandle } from './nodeTransformGeometry'; @@ -57,6 +62,7 @@ const RESIZE_HANDLES: ReadonlyArray<{ function RenderNode({ node, isRoot, + parentContainer, renderMode, showFrame, hiddenNodeIds, @@ -70,12 +76,19 @@ function RenderNode({ onNodeResizePointerMove, onNodeResizePointerUp, viewportScale, -}: Omit & { node: UiNode; isRoot?: boolean }) { +}: Omit & { + node: UiNode; + isRoot?: boolean; + parentContainer?: UiNode['layout']['container']; +}) { if (hiddenNodeIds.has(node.id)) return null; let geometry; try { - geometry = tf2css(node.transform); + geometry = controlLayoutToPreviewCss( + node.layout, + parentContainer !== undefined, + ); } catch { // Keep malformed nodes isolated from the rest of the tree. return null; @@ -94,6 +107,10 @@ function RenderNode({ className={`absolute min-h-0 min-w-0 ${isRoot ? 'cursor-default' : 'cursor-move'}`} style={{ ...geometry, + ...(parentContainer + ? childInContainerToPreviewCss(node.layout, parentContainer) + : {}), + ...containerToPreviewCss(node.layout.container), ...(isFrameVisible ? { outline: @@ -112,10 +129,12 @@ function RenderNode({ event.stopPropagation(); onSelectNode(node.id); }} - onPointerDown={(event) => onNodePointerDown(event, node)} - onPointerMove={onNodePointerMove} - onPointerUp={onNodePointerUp} - onPointerCancel={onNodePointerUp} + onPointerDown={ + parentContainer ? undefined : (event) => onNodePointerDown(event, node) + } + onPointerMove={parentContainer ? undefined : onNodePointerMove} + onPointerUp={parentContainer ? undefined : onNodePointerUp} + onPointerCancel={parentContainer ? undefined : onNodePointerUp} title={isFrameVisible ? node.metadata.name || undefined : undefined} > {isFrameVisible && node.metadata.name ? ( @@ -136,6 +155,11 @@ function RenderNode({ ), )} - {isFrameVisible && !isRoot && selectedNodeId === node.id + {isFrameVisible && + !isRoot && + !parentContainer && + selectedNodeId === node.id ? RESIZE_HANDLES.map((handle) => (
{ + it('maps a Godot HBoxContainer to preview CSS and disables child free-transform handles', () => { + const hboxTree: UITree = { + src_ui_design: 'page', + root: { + ...node('root', [node('first'), node('second')]), + layout: { + ...node('root').layout, + container: { HBox: { alignment: 'Begin', separation: 12 } }, + }, + }, + }; + const rendered = render( + , + ); + const root = rendered.container.querySelector( + '[data-node-id="root"]', + ) as HTMLDivElement; + const first = rendered.container.querySelector( + '[data-node-id="first"]', + ) as HTMLDivElement; + expect(root.style.display).toBe('flex'); + expect(root.style.flexDirection).toBe('row'); + expect(root.style.gap).toBe('12px'); + expect(first.style.position).toBe('relative'); + expect(screen.queryByLabelText('nw 调整控制点')).toBeNull(); + }); + it('shows final-preview node frames and resize handles only when requested', () => { const withoutFrames = renderTree('final-preview', new Set(), { selectedNodeId: 'child', diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index 8c9f3e6ea..87af21e40 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -53,11 +53,18 @@ function font(id: string): FontAsset { function nodeWithSprite(id: string): Node { return { id, - transform: { - anchor_min: [0, 0], - anchor_max: [0, 0], - offset_min: [0, 0], - offset_max: [32, 32], + layout: { + transform: { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [0, 0], + offset_max: [32, 32], + }, + custom_minimum_size: [0, 0], + size_flags_horizontal: 1, + size_flags_vertical: 1, + size_flags_stretch_ratio: 1, + container: 'None', }, metadata: { name: 'Image', @@ -83,11 +90,18 @@ function nodeWithSprite(id: string): Node { function pageRoot(id: string, children: Node[] = []): Node { return { id, - transform: { - anchor_min: [0, 0], - anchor_max: [1, 1], - offset_min: [0, 0], - offset_max: [0, 0], + layout: { + transform: { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], + }, + custom_minimum_size: [0, 0], + size_flags_horizontal: 1, + size_flags_vertical: 1, + size_flags_stretch_ratio: 1, + container: 'None', }, metadata: { name: id, @@ -119,7 +133,7 @@ describe('useUiEditorState', () => { ], }; const { result } = renderHook(() => useUiEditorState(initial)); - const originalTransform = structuredClone(moved.transform); + const originalTransform = structuredClone(moved.layout.transform); act(() => { expect( @@ -136,13 +150,13 @@ describe('useUiEditorState', () => { expect(result.current.state.ui_trees[0]?.root.children).toEqual([]); expect(result.current.state.ui_trees[1]?.root.children[0]).toMatchObject({ id: 'moved', - transform: originalTransform, + layout: { transform: originalTransform }, }); }); it('keeps page position stable when reparenting within one UI tree', () => { const moved = nodeWithSprite('moved'); - moved.transform = { + moved.layout.transform = { anchor_min: [0, 0], anchor_max: [1, 1], offset_min: [0, 0], @@ -157,21 +171,27 @@ describe('useUiEditorState', () => { root: pageRoot('page-root', [ { ...pageRoot('source-parent'), - transform: { - anchor_min: [0, 0], - anchor_max: [0.5, 0.5], - offset_min: [0, 0], - offset_max: [0, 0], + layout: { + ...pageRoot('source-parent').layout, + transform: { + anchor_min: [0, 0], + anchor_max: [0.5, 0.5], + offset_min: [0, 0], + offset_max: [0, 0], + }, }, children: [moved], }, { ...pageRoot('target-parent'), - transform: { - anchor_min: [0.5, 0.5], - anchor_max: [1, 1], - offset_min: [0, 0], - offset_max: [0, 0], + layout: { + ...pageRoot('target-parent').layout, + transform: { + anchor_min: [0.5, 0.5], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], + }, }, }, ]), @@ -196,8 +216,8 @@ describe('useUiEditorState', () => { result.current.state.ui_trees[0]?.root.children[0]?.children, ).toEqual([]); expect( - result.current.state.ui_trees[0]?.root.children[1]?.children[0] - ?.transform, + result.current.state.ui_trees[0]?.root.children[1]?.children[0]?.layout + .transform, ).toMatchObject({ anchor_min: [0, 0], anchor_max: [1, 1], @@ -364,11 +384,18 @@ describe('useUiEditorState', () => { src_ui_design: 'page', root: { id: 'page-root', - transform: { - anchor_min: [0, 0], - anchor_max: [1, 1], - offset_min: [0, 0], - offset_max: [0, 0], + layout: { + transform: { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], + }, + custom_minimum_size: [0, 0], + size_flags_horizontal: 1, + size_flags_vertical: 1, + size_flags_stretch_ratio: 1, + container: 'None', }, metadata: { name: '页面根节点', @@ -409,7 +436,7 @@ describe('useUiEditorState', () => { result.current.removeDesignImage('page', { dryRun: false }); }); expect(result.current.state.sprite_assets.panel).toBeUndefined(); - expect(result.current.state.ui_trees).toEqual([]); + expect(result.current.state.ui_trees).toHaveLength(1); expect(result.current.state.ui_design_images.page).toBeUndefined(); expect( result.current.state.ui_design_images.child?.metadata.slave_to, diff --git a/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md new file mode 100644 index 000000000..b7981c5bc --- /dev/null +++ b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md @@ -0,0 +1,41 @@ +# UI 编辑器 Godot 容器布局模型 + +更新时间:`2026-08-18` + +## 范围 + +UI 编辑器节点统一采用 Godot 4.x `Control` 风格布局数据。Rust 负责持久化和参数校验;UI Editor 前端将该数据单向生成 Preview CSS。CSS 只是预览后端,不是领域模型,也不需要与 Godot 项目或运行时对接。 + +本次是破坏性 UI 设计 schema 变更:旧 `Node.transform` 改为 `Node.layout.transform`,不提供迁移。 + +## 数据模型 + +每个 `Node` 保留: + +```text +layout: { + transform, // anchors + offsets + custom_minimum_size: [x, y], + size_flags_horizontal: 0|1|2|3|4|8, + size_flags_vertical: 0|1|2|3|4|8, + size_flags_stretch_ratio, + container: None | HBox | VBox | Grid | Margin | Center +} +``` + +flags 使用 Godot 数值:`ShrinkBegin=0`、`Fill=1`、`Expand=2`、`ExpandFill=3`、`ShrinkCenter=4`、`ShrinkEnd=8`。默认最小尺寸为 `[0, 0]`,flags 为 `Fill`,stretch ratio 为 `1`。 + +Container 专属数据是互斥 tagged union:HBox/VBox 存 `alignment + separation`,Grid 存 `columns + h_separation + v_separation`,Margin 存四边 margin,Center 存 `use_top_left`。 + +## 语义 + +- 普通 Control:children 继续按自己的 Transform 绝对定位。 +- 父为 Container:父忽略 direct child 的 Transform,使用 child minimum size、flags 与 stretch ratio;Transform 不删除,离开 Container 后继续可用。 +- `children_display_mode` 仍是独立的 Stack/Exclusive 可见性规则;不可见 child 不参与 Container。 +- 不支持 Flex/Flow wrap;Grid 按行优先排列。 +- Margin 的 children 填满内侧矩形;Center 的 children 居中并可重叠。 +- v1 minimum size 只使用 `custom_minimum_size`。TODO:为文本、图片等组件提供 minimum-size,再与 custom minimum 逐轴取最大值。 + +## 编辑器交互 + +布局只在 Inspector 编辑。Preview 不为 Container 管理的 child 提供拖拽或缩放手柄;Inspector 会提示其 Transform 被父 Container 忽略。普通 Control 仍保留原 Transform 编辑和自由预览操作。