From f09fbeba7a950376e910a099dd30d190ba19ccea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 13 Aug 2026 14:10:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20UI=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E6=A0=B8=E5=BF=83=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 UI 设计图片和精灵素材批处理准备功能 - 新增界面图和组件前置条件校验逻辑 - 添加与 SpriteBorder 相关的验证和字段编辑组件 - 支持 UI 状态管理,包括设计图和精灵素材的增删改功能 --- .../src/features/ui-editor/importAdapter.ts | 98 +++++ .../src/features/ui-editor/prerequisites.ts | 147 +++++++ .../src/features/ui-editor/spriteBorder.ts | 68 +++ .../features/ui-editor/useUiEditorState.ts | 416 ++++++++++++++++++ .../ui-editor/components/EditorDialogs.tsx | 89 ++++ .../ui-editor/components/InputSidebar.tsx | 133 ++++++ .../components/Inspector/InspectorSidebar.tsx | 205 +++++++++ .../SpriteBorder/SpriteBorderEditor.tsx | 115 +++++ .../SpriteBorder/SpriteBorderFields.tsx | 134 ++++++ .../SpriteBorder/SpriteBorderGuideEditor.tsx | 212 +++++++++ .../components/PrerequisiteIssues.tsx | 27 ++ .../ui-editor/components/PreviewWorkspace.tsx | 66 +++ .../ui-editor/components/ToolNavigation.tsx | 39 ++ .../view/ui-editor/components/UiTreePanel.tsx | 47 ++ .../src/view/ui-editor/index.tsx | 29 ++ .../src/view/ui-editor/model.ts | 53 +++ .../src/view/ui-editor/useUiEditorPage.ts | 293 ++++++++++++ .../tests/uiEditorPage.test.ts | 53 +++ .../tests/uiEditorState.test.ts | 219 +++++++++ 19 files changed, 2443 insertions(+) create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/importAdapter.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/prerequisites.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/spriteBorder.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/EditorDialogs.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/SpriteBorder/SpriteBorderEditor.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/SpriteBorder/SpriteBorderFields.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/SpriteBorder/SpriteBorderGuideEditor.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/PrerequisiteIssues.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/PreviewWorkspace.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/ToolNavigation.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/index.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/model.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts create mode 100644 apps/ai-game-creator-shell/tests/uiEditorPage.test.ts create mode 100644 apps/ai-game-creator-shell/tests/uiEditorState.test.ts diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/importAdapter.ts b/apps/ai-game-creator-shell/src/features/ui-editor/importAdapter.ts new file mode 100644 index 000000000..9c83db4de --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/importAdapter.ts @@ -0,0 +1,98 @@ +import { invoke } from '@tauri-apps/api/core'; + +import type { ImportedAsset } from '../../components/AssetImporter'; +import type { SpriteAsset } from './types/SpriteAsset'; +import type { UIDesignImage } from './types/UIDesignImage'; +import type { UIDesignImageId } from './types/UIDesignImageId'; +import type { DesignImageInput } from './useUiEditorState'; + +type ImagePreviewResponse = { dataUrl: string }; + +export type PreparedImageAsset = { + resource: T; + previewUrl: string; +}; + +function basename(path: string) { + return path.replaceAll('\\', '/').split('/').at(-1) || 'image'; +} + +export function decodeImageSize(src: string): Promise<[number, number]> { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => { + if (image.naturalWidth <= 0 || image.naturalHeight <= 0) { + reject(new Error('图片尺寸无效')); + return; + } + resolve([image.naturalWidth, image.naturalHeight]); + }; + image.onerror = () => reject(new Error('图片无法解码')); + image.src = src; + }); +} + +async function readImportedImage( + projectPath: string, + asset: ImportedAsset, +) { + const preview = await invoke( + 'read_local_project_image_preview', + { projectPath, relativePath: asset.localPath }, + ); + return { + previewUrl: preview.dataUrl, + pixelSize: await decodeImageSize(preview.dataUrl), + }; +} + +export async function prepareDesignImageBatch( + projectPath: string, + assets: readonly ImportedAsset[], +): Promise>> { + return Promise.all( + assets.map(async (asset) => { + const { previewUrl, pixelSize } = await readImportedImage( + projectPath, + asset, + ); + const image: UIDesignImage = { + metadata: { + name: basename(asset.localPath), + role: null, + slave_to: null, + }, + path: asset.localPath, + pixel_size: pixelSize, + pixels_per_unit: 1, + }; + return { + resource: { id: asset.id as UIDesignImageId, image }, + previewUrl, + }; + }), + ); +} + +export async function prepareSpriteAssetBatch( + projectPath: string, + assets: readonly ImportedAsset[], +): Promise>> { + return Promise.all( + assets.map(async (asset) => { + const { previewUrl, pixelSize } = await readImportedImage( + projectPath, + asset, + ); + const resource: SpriteAsset = { + asset_id: asset.id, + metadata: { name: basename(asset.localPath), asset_type: '' }, + path: asset.localPath, + pixel_size: pixelSize, + pixels_per_unit: 1, + border: { left: 0, right: 0, top: 0, bottom: 0 }, + }; + return { resource, previewUrl }; + }), + ); +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/prerequisites.ts b/apps/ai-game-creator-shell/src/features/ui-editor/prerequisites.ts new file mode 100644 index 000000000..7b7bbcc79 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/prerequisites.ts @@ -0,0 +1,147 @@ +import type { State } from './types/State'; +import type { UIDesignImageId } from './types/UIDesignImageId'; + +export type UiEditorPrerequisiteIssue = { + code: string; + message: string; + resourceId?: string; +}; + +function imageResourceIssues(state: State): UiEditorPrerequisiteIssue[] { + const issues: UiEditorPrerequisiteIssue[] = []; + for (const [id, image] of Object.entries(state.ui_design_images)) { + if ( + !image.path.trim() || + image.pixel_size.some( + (value) => !Number.isFinite(value) || value <= 0, + ) || + !Number.isFinite(image.pixels_per_unit) || + image.pixels_per_unit <= 0 + ) { + issues.push({ + code: 'invalid-design-image', + message: '界面图无法读取或尺寸无效', + resourceId: id, + }); + } + } + return issues; +} + +export function validateComponentRecognitionPrerequisites( + state: State, +): UiEditorPrerequisiteIssue[] { + const ids = Object.keys(state.ui_design_images) as UIDesignImageId[]; + const issues = imageResourceIssues(state); + if (ids.length === 0) { + issues.push({ code: 'missing-design-image', message: '请先导入界面图' }); + } + if (ids.length > 4) { + issues.push({ code: 'design-image-limit', message: '界面图最多 4 张' }); + } + for (const id of ids) { + const image = state.ui_design_images[id]!; + const { role, slave_to: slaveTo } = image.metadata; + if (role === 'Page' && slaveTo !== null) { + issues.push({ + code: 'page-has-slave-to', + message: '主页面不能设置归属页面', + resourceId: id, + }); + } else if (role !== null && role !== 'Page') { + if (slaveTo === null) { + issues.push({ + code: 'missing-slave-to', + message: '该界面角色需要选择归属主页面', + resourceId: id, + }); + } else if (slaveTo === id) { + issues.push({ + code: 'self-slave-to', + message: '界面不能归属于自身', + resourceId: id, + }); + } else if ( + state.ui_design_images[slaveTo]?.metadata.role !== 'Page' + ) { + issues.push({ + code: 'invalid-slave-to', + message: '归属页面必须是有效主页面', + resourceId: id, + }); + } + } + } + return issues; +} + +export function validateAssetRecognitionPrerequisites( + state: State, +): UiEditorPrerequisiteIssue[] { + const issues = validateComponentRecognitionPrerequisites(state); + if (state.ui_trees.length === 0) { + issues.push({ + code: 'missing-ui-tree', + message: '请先完成组件识别', + }); + } + for (const tree of state.ui_trees) { + if (!(tree.src_ui_design in state.ui_design_images)) { + issues.push({ + code: 'missing-tree-design-image', + message: '组件树引用的界面图不存在', + resourceId: tree.src_ui_design, + }); + } + } + return issues; +} + +export function validateLayoutGenerationPrerequisites( + state: State, +): UiEditorPrerequisiteIssue[] { + const issues = validateAssetRecognitionPrerequisites(state); + if (Object.keys(state.sprite_assets).length === 0) { + issues.push({ + code: 'missing-sprite-assets', + message: '请先导入并绑定独立素材', + }); + } + const visit = (nodes: State['ui_trees'][number]['children']) => { + for (const node of nodes) { + for (const component of node.components) { + if ( + 'Image' in component && + component.Image.target_graphic !== null && + !(component.Image.target_graphic in state.sprite_assets) + ) { + issues.push({ + code: 'missing-target-graphic', + message: '图片组件引用的独立素材不存在', + resourceId: component.Image.target_graphic, + }); + } + if ( + 'Text' in component && + component.Text.font !== null && + !(component.Text.font in state.font_assets) + ) { + issues.push({ + code: 'missing-font', + message: '文本组件引用的字体不存在', + resourceId: component.Text.font, + }); + } + } + visit(node.children); + } + }; + for (const tree of state.ui_trees) visit(tree.children); + return issues; +} + +export function validateLayoutReviewPrerequisites( + state: State, +): UiEditorPrerequisiteIssue[] { + return validateLayoutGenerationPrerequisites(state); +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/spriteBorder.ts b/apps/ai-game-creator-shell/src/features/ui-editor/spriteBorder.ts new file mode 100644 index 000000000..f61093eed --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/spriteBorder.ts @@ -0,0 +1,68 @@ +import type { SpriteBorder } from './types/SpriteBorder'; + +const MAX_U32 = 4_294_967_295; + +export type SpriteBorderValidation = + | { ok: true } + | { ok: false; message: string; axis?: 'horizontal' | 'vertical' }; + +export function spriteBorderIsClear(border: SpriteBorder) { + return ( + border.left === 0 && + border.right === 0 && + border.top === 0 && + border.bottom === 0 + ); +} + +export function validateSpriteBorder( + pixelSize: [number, number], + border: SpriteBorder, +): SpriteBorderValidation { + const values = [border.left, border.right, border.top, border.bottom]; + if ( + values.some( + (value) => + !Number.isInteger(value) || value < 0 || value > MAX_U32, + ) + ) { + return { ok: false, message: '边距必须是非负整数像素' }; + } + if (spriteBorderIsClear(border)) return { ok: true }; + + const width = Math.floor(pixelSize[0]); + const height = Math.floor(pixelSize[1]); + if (border.left + border.right + 1 > width) { + return { + ok: false, + axis: 'horizontal', + message: '水平中心至少保留 1 px', + }; + } + if (border.top + border.bottom + 1 > height) { + return { + ok: false, + axis: 'vertical', + message: '垂直中心至少保留 1 px', + }; + } + return { ok: true }; +} + +export function spriteBorderRatioPreset( + pixelSize: [number, number], + centerRatio: 1 | 2, +): SpriteBorder | null { + const width = Math.floor(pixelSize[0]); + const height = Math.floor(pixelSize[1]); + const divisor = centerRatio + 2; + const horizontal = Math.floor(width / divisor); + const vertical = Math.floor(height / divisor); + if (horizontal === 0 || vertical === 0) return null; + return { + left: horizontal, + right: horizontal, + top: vertical, + bottom: vertical, + }; +} 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 new file mode 100644 index 000000000..e3338ba59 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -0,0 +1,416 @@ +import { useCallback, useRef, useState } from 'react'; + +import type { Component } from './types/Component'; +import { validateSpriteBorder } from './spriteBorder'; +import type { Node } from './types/Node'; +import type { SpriteAsset } from './types/SpriteAsset'; +import type { SpriteAssetId } from './types/SpriteAssetId'; +import type { SpriteBorder } from './types/SpriteBorder'; +import type { State } from './types/State'; +import type { UIDesignImage } from './types/UIDesignImage'; +import type { UIDesignImageId } from './types/UIDesignImageId'; +import type { UIDesignImageRole } from './types/UIDesignImageRole'; + +export const EMPTY_UI_EDITOR_STATE: State = { + ui_trees: [], + ui_design_images: {}, + sprite_assets: {}, + font_assets: {}, +}; + +export type UiEditorOperationFailureReason = + | 'locked' + | 'duplicate' + | 'limit' + | 'missing' + | 'invalid'; + +export type UiEditorOperationResult = + | { ok: true; value: T } + | { ok: false; reason: UiEditorOperationFailureReason }; + +type UiEditorOperationFailure = Extract< + UiEditorOperationResult, + { ok: false } +>; + +export type DesignImageInput = { + id: UIDesignImageId; + image: UIDesignImage; +}; + +export type RemovalImpact = { + removedResourceCount: number; + removedTreeCount: number; + clearedSlaveToCount: number; + clearedTargetGraphicCount: number; +}; + +function cloneState(state: State): State { + return structuredClone(state); +} + +function visitComponents( + nodes: Node[], + visit: (component: Component) => void, +) { + for (const node of nodes) { + for (const component of node.components) { + visit(component); + } + visitComponents(node.children, visit); + } +} + +export function designImageRemovalImpact( + state: State, + id: UIDesignImageId, +): RemovalImpact { + return { + removedResourceCount: id in state.ui_design_images ? 1 : 0, + removedTreeCount: state.ui_trees.filter( + (tree) => tree.src_ui_design === id, + ).length, + clearedSlaveToCount: Object.values(state.ui_design_images).filter( + (image) => image.metadata.slave_to === id, + ).length, + clearedTargetGraphicCount: 0, + }; +} + +export function spriteAssetRemovalImpact( + state: State, + id: SpriteAssetId, +): RemovalImpact { + let clearedTargetGraphicCount = 0; + for (const tree of state.ui_trees) { + visitComponents(tree.children, (component) => { + if ( + 'Image' in component && + component.Image.target_graphic === id + ) { + clearedTargetGraphicCount += 1; + } + }); + } + return { + removedResourceCount: id in state.sprite_assets ? 1 : 0, + removedTreeCount: 0, + clearedSlaveToCount: 0, + clearedTargetGraphicCount, + }; +} + +function validImageResource(image: UIDesignImage) { + return ( + image.path.trim().length > 0 && + image.metadata.name.trim().length > 0 && + image.pixel_size.every( + (value) => Number.isFinite(value) && value > 0, + ) && + Number.isFinite(image.pixels_per_unit) && + image.pixels_per_unit > 0 + ); +} + +function validSpriteResource(sprite: SpriteAsset) { + return ( + sprite.asset_id.trim().length > 0 && + sprite.path.trim().length > 0 && + sprite.metadata.name.trim().length > 0 && + sprite.pixel_size.every( + (value) => Number.isFinite(value) && value > 0, + ) && + Number.isFinite(sprite.pixels_per_unit) && + sprite.pixels_per_unit > 0 && + validateSpriteBorder(sprite.pixel_size, sprite.border).ok + ); +} + +export function useUiEditorState( + initialState: State = EMPTY_UI_EDITOR_STATE, +) { + const [state, setState] = useState(() => cloneState(initialState)); + const [isLocked, setIsLocked] = useState(false); + const stateRef = useRef(state); + const isLockedRef = useRef(false); + stateRef.current = state; + + const commit = useCallback((nextState: State) => { + stateRef.current = nextState; + setState(nextState); + }, []); + + const guard = useCallback((): UiEditorOperationFailure | null => { + // Every semantic write exits before reading or committing State while locked. + return isLockedRef.current ? { ok: false, reason: 'locked' } : null; + }, []); + + const runWithStateLocked = useCallback( + async (operation: (snapshot: State) => Promise): Promise => { + if (isLockedRef.current) { + throw new Error('UI editor State is already locked'); + } + + // Acquire synchronously so another operation cannot enter before React + // renders isLocked=true. The callback receives an isolated, stable + // snapshot; all editor writes remain blocked until it settles. + isLockedRef.current = true; + setIsLocked(true); + const snapshot = cloneState(stateRef.current); + try { + return await operation(snapshot); + } finally { + isLockedRef.current = false; + setIsLocked(false); + } + }, + [], + ); + + const setImageName = useCallback( + (id: UIDesignImageId, name: string): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.ui_design_images)) { + return { ok: false, reason: 'missing' }; + } + const next = cloneState(current); + next.ui_design_images[id]!.metadata.name = name; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setImageRole = useCallback( + ( + id: UIDesignImageId, + role: UIDesignImageRole | null, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.ui_design_images)) { + return { ok: false, reason: 'missing' }; + } + const next = cloneState(current); + next.ui_design_images[id]!.metadata.role = role; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setImageSlaveTo = useCallback( + ( + id: UIDesignImageId, + slaveTo: UIDesignImageId | null, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.ui_design_images)) { + return { ok: false, reason: 'missing' }; + } + if (slaveTo !== null && !(slaveTo in current.ui_design_images)) { + return { ok: false, reason: 'missing' }; + } + const next = cloneState(current); + next.ui_design_images[id]!.metadata.slave_to = slaveTo; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const addDesignImages = useCallback( + (entries: readonly DesignImageInput[]): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const ids = entries.map((entry) => entry.id); + if (new Set(ids).size !== ids.length) { + return { ok: false, reason: 'duplicate' }; + } + if (ids.some((id) => id in current.ui_design_images)) { + return { ok: false, reason: 'duplicate' }; + } + if (Object.keys(current.ui_design_images).length + entries.length > 4) { + return { ok: false, reason: 'limit' }; + } + if (entries.some((entry) => !validImageResource(entry.image))) { + return { ok: false, reason: 'invalid' }; + } + const next = cloneState(current); + for (const entry of entries) { + next.ui_design_images[entry.id] = structuredClone(entry.image); + } + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const addSpriteAssets = useCallback( + (assets: readonly SpriteAsset[]): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const ids = assets.map((asset) => asset.asset_id); + if ( + new Set(ids).size !== ids.length || + ids.some((id) => id in current.sprite_assets) + ) { + return { ok: false, reason: 'duplicate' }; + } + if (assets.some((asset) => !validSpriteResource(asset))) { + return { ok: false, reason: 'invalid' }; + } + const next = cloneState(current); + for (const asset of assets) { + next.sprite_assets[asset.asset_id] = structuredClone(asset); + } + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setSpriteName = useCallback( + (id: SpriteAssetId, name: string): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.sprite_assets)) { + return { ok: false, reason: 'missing' }; + } + const next = cloneState(current); + next.sprite_assets[id]!.metadata.name = name; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setSpriteAssetType = useCallback( + (id: SpriteAssetId, assetType: string): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.sprite_assets)) { + return { ok: false, reason: 'missing' }; + } + const next = cloneState(current); + next.sprite_assets[id]!.metadata.asset_type = assetType; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setSpriteBorder = useCallback( + ( + id: SpriteAssetId, + border: SpriteBorder, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const sprite = current.sprite_assets[id]; + if (!sprite) return { ok: false, reason: 'missing' }; + if (!validateSpriteBorder(sprite.pixel_size, border).ok) { + return { ok: false, reason: 'invalid' }; + } + const next = cloneState(current); + next.sprite_assets[id]!.border = structuredClone(border); + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const removeDesignImage = useCallback( + ( + id: UIDesignImageId, + options: { dryRun: boolean }, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.ui_design_images)) { + return { ok: false, reason: 'missing' }; + } + const impact = designImageRemovalImpact(current, id); + if (options.dryRun) return { ok: true, value: impact }; + const next = cloneState(current); + delete next.ui_design_images[id]; + next.ui_trees = next.ui_trees.filter( + (tree) => tree.src_ui_design !== id, + ); + for (const image of Object.values(next.ui_design_images)) { + if (image.metadata.slave_to === id) image.metadata.slave_to = null; + } + commit(next); + return { ok: true, value: impact }; + }, + [commit, guard], + ); + + const removeSpriteAsset = useCallback( + ( + id: SpriteAssetId, + options: { dryRun: boolean }, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.sprite_assets)) { + return { ok: false, reason: 'missing' }; + } + const impact = spriteAssetRemovalImpact(current, id); + if (options.dryRun) return { ok: true, value: impact }; + const next = cloneState(current); + delete next.sprite_assets[id]; + for (const tree of next.ui_trees) { + visitComponents(tree.children, (component) => { + if ( + 'Image' in component && + component.Image.target_graphic === id + ) { + component.Image.target_graphic = null; + } + }); + } + commit(next); + return { ok: true, value: impact }; + }, + [commit, guard], + ); + + const clearState = useCallback((): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + commit(cloneState(EMPTY_UI_EDITOR_STATE)); + return { ok: true, value: undefined }; + }, [commit, guard]); + + return { + state, + + isLocked, + runWithStateLocked, + setImageName, + setImageRole, + setImageSlaveTo, + addDesignImages, + addSpriteAssets, + setSpriteName, + setSpriteAssetType, + setSpriteBorder, + removeDesignImage, + removeSpriteAsset, + clearState, + }; +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/EditorDialogs.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/EditorDialogs.tsx new file mode 100644 index 000000000..60ed0142b --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/EditorDialogs.tsx @@ -0,0 +1,89 @@ +import { AssetImporter } from '../../../components/AssetImporter'; +import { ThemedModal } from '../../../components/modal/ThemedModal'; +import type { UiEditorPageController } from '../useUiEditorPage'; + +export function EditorDialogs({ + controller, +}: { + controller: UiEditorPageController; +}) { + const impact = controller.pendingRemoval?.impact; + return ( + <> + { + void controller.importAssets(assets); + controller.closeImporter(); + }} + maxItems={controller.importKind === 'design-image' ? 4 : 100} + maxFileSize={20 * 1024 * 1024} + acceptedMediaTypes={['image/png', 'image/jpeg', 'image/webp']} + /> + + +

清空全部 State?

+

+ 界面图、独立素材、组件树和绑定都会从当前编辑器 State 中移除。 +

+
+ + +
+
+ + +

删除并清理引用?

+

+ 该资源仍被 State 使用。确认后会删除资源,并清理当时最新 State + 中的全部相关引用。 +

+
    +
  • 删除组件树:{impact?.removedTreeCount ?? 0}
  • +
  • 清空界面归属:{impact?.clearedSlaveToCount ?? 0}
  • +
  • 清空图片组件引用:{impact?.clearedTargetGraphicCount ?? 0}
  • +
+
+ + +
+
+ + ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx new file mode 100644 index 000000000..a760c58b7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx @@ -0,0 +1,133 @@ +import { Image as ImageIcon, Plus } from 'lucide-react'; + +import type { UiEditorPageController } from '../useUiEditorPage'; +import { UiTreePanel } from './UiTreePanel'; + +export function InputSidebar({ controller }: { controller: UiEditorPageController }) { + // TODO: AssetImporter 支持字体文件后,在这里增加独立的项目字体面板。 + const { + projectPath, + editor, + imageOrder, + activeImageId, + previewUrls, + images, + sprites, + spriteReferenceCounts, + activeTool, + treeForActiveImage, + } = controller; + + return ( + + ); +} 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 new file mode 100644 index 000000000..27a89c2db --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx @@ -0,0 +1,205 @@ +import { Boxes, Trash2 } from 'lucide-react'; + +import type { UIDesignImageId } from '../../../../features/ui-editor/types/UIDesignImageId'; +import type { UIDesignImageRole } from '../../../../features/ui-editor/types/UIDesignImageRole'; +import { UI_DESIGN_IMAGE_ROLES } from '../../model'; +import type { UiEditorPageController } from '../../useUiEditorPage'; +import { PrerequisiteIssues } from '../PrerequisiteIssues'; +import { SpriteBorderEditor } from './SpriteBorder/SpriteBorderEditor'; + +export function InspectorSidebar({ + controller, +}: { + controller: UiEditorPageController; +}) { + const { + activeImage, + activeImageId, + selectedSprite, + selectedSpriteId, + previewUrls, + spriteReferenceCounts, + pageOptions, + } = controller; + + return ( + + ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + {value} +
+ ); +} + +function ResourceId({ value }: { value: string }) { + return ( +
+ {value} +
+ ); +} + +function DeleteResourceButton({ + label, + disabled, + onClick, +}: { + label: string; + disabled: boolean; + onClick: () => void; +}) { + return ( +
+ +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/SpriteBorder/SpriteBorderEditor.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/SpriteBorder/SpriteBorderEditor.tsx new file mode 100644 index 000000000..c7fbdd89e --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/SpriteBorder/SpriteBorderEditor.tsx @@ -0,0 +1,115 @@ +import { ChevronDown } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +import { + spriteBorderIsClear, + spriteBorderRatioPreset, +} from '../../../../../features/ui-editor/spriteBorder'; +import type { SpriteAssetId } from '../../../../../features/ui-editor/types/SpriteAssetId'; +import type { SpriteBorder } from '../../../../../features/ui-editor/types/SpriteBorder'; +import { SpriteBorderFields } from './SpriteBorderFields'; +import { SpriteBorderGuideEditor } from './SpriteBorderGuideEditor'; + +const CLEAR_BORDER: SpriteBorder = { left: 0, right: 0, top: 0, bottom: 0 }; + +export function SpriteBorderEditor({ + spriteId, + border, + pixelSize, + previewUrl, + onChange, +}: { + spriteId: SpriteAssetId; + border: SpriteBorder; + pixelSize: [number, number]; + previewUrl?: string; + onChange: (border: SpriteBorder) => void; +}) { + const [open, setOpen] = useState(() => !spriteBorderIsClear(border)); + const previousSpriteId = useRef(spriteId); + + useEffect(() => { + if (previousSpriteId.current === spriteId) return; + previousSpriteId.current = spriteId; + setOpen(!spriteBorderIsClear(border)); + }, [border, spriteId]); + + const preset121 = spriteBorderRatioPreset(pixelSize, 2); + const preset111 = spriteBorderRatioPreset(pixelSize, 1); + + return ( +
+ + + {open ? ( +
+
+ preset121 && onChange(preset121)} + /> + preset111 && onChange(preset111)} + /> + onChange(CLEAR_BORDER)} + /> +
+ + +
+ ) : null} +
+ ); +} + +function PresetButton({ + label, + disabled, + onClick, +}: { + label: string; + disabled: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/SpriteBorder/SpriteBorderFields.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/SpriteBorder/SpriteBorderFields.tsx new file mode 100644 index 000000000..576cc3a97 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/SpriteBorder/SpriteBorderFields.tsx @@ -0,0 +1,134 @@ +import { type KeyboardEvent, useEffect, useRef, useState } from 'react'; + +import { validateSpriteBorder } from '../../../../../features/ui-editor/spriteBorder'; +import type { SpriteBorder } from '../../../../../features/ui-editor/types/SpriteBorder'; + +type BorderSide = keyof SpriteBorder; +type BorderDraft = Record; + +const SIDES: Array<{ side: BorderSide; label: string }> = [ + { side: 'left', label: '左' }, + { side: 'right', label: '右' }, + { side: 'top', label: '上' }, + { side: 'bottom', label: '下' }, +]; + +function borderToDraft(border: SpriteBorder): BorderDraft { + return Object.fromEntries( + SIDES.map(({ side }) => [side, String(border[side])]), + ) as BorderDraft; +} + +function draftToBorder(draft: BorderDraft): SpriteBorder | null { + if (SIDES.some(({ side }) => draft[side].trim() === '')) return null; + return Object.fromEntries( + SIDES.map(({ side }) => [side, Number(draft[side])]), + ) as SpriteBorder; +} + +export function SpriteBorderFields({ + border, + pixelSize, + onChange, +}: { + border: SpriteBorder; + pixelSize: [number, number]; + onChange: (border: SpriteBorder) => void; +}) { + const [draft, setDraft] = useState(() => borderToDraft(border)); + const groupRef = useRef(null); + + useEffect(() => setDraft(borderToDraft(border)), [border]); + + const parsed = draftToBorder(draft); + const validation = parsed + ? validateSpriteBorder(pixelSize, parsed) + : { ok: false as const, message: '请输入完整的非负整数像素' }; + + function commit() { + if (!parsed || !validation.ok) return false; + onChange(parsed); + return true; + } + + function reset() { + setDraft(borderToDraft(border)); + } + + function handleKeyDown( + side: BorderSide, + event: KeyboardEvent, + ) { + if (event.key === 'Escape') { + event.preventDefault(); + reset(); + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + commit(); + return; + } + if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return; + event.preventDefault(); + const step = event.shiftKey ? 10 : 1; + const direction = event.key === 'ArrowUp' ? 1 : -1; + const current = Number(draft[side]) || 0; + setDraft((value) => ({ + ...value, + [side]: String(Math.max(0, current + direction * step)), + })); + } + + return ( + <> +
{ + if ( + !groupRef.current?.contains(event.relatedTarget) && + !commit() + ) { + reset(); + } + }} + > + {SIDES.map(({ side, label }) => { + const invalid = + !validation.ok && + (!('axis' in validation) || + validation.axis === + (side === 'left' || side === 'right' + ? 'horizontal' + : 'vertical')); + return ( + + ); + })} +
+ {!validation.ok ? ( +

{validation.message}

+ ) : null} + + ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/SpriteBorder/SpriteBorderGuideEditor.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/SpriteBorder/SpriteBorderGuideEditor.tsx new file mode 100644 index 000000000..2945f6f41 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/SpriteBorder/SpriteBorderGuideEditor.tsx @@ -0,0 +1,212 @@ +import { type PointerEvent, useEffect, useRef, useState } from 'react'; + +import type { SpriteBorder } from '../../../../../features/ui-editor/types/SpriteBorder'; + +type BorderSide = keyof SpriteBorder; +const SIDES: BorderSide[] = ['left', 'right', 'top', 'bottom']; + +export function SpriteBorderGuideEditor({ + border, + pixelSize, + previewUrl, + onChange, +}: { + border: SpriteBorder; + pixelSize: [number, number]; + previewUrl?: string; + onChange: (border: SpriteBorder) => void; +}) { + const [draft, setDraft] = useState(border); + const [active, setActive] = useState(null); + const activeRef = useRef(null); + const draftRef = useRef(border); + const previewRef = useRef(null); + + useEffect(() => { + setDraft(border); + draftRef.current = border; + activeRef.current = null; + setActive(null); + }, [border]); + + if (!previewUrl) { + return ( +

+ 预览不可用,可使用数值或预设编辑 +

+ ); + } + + function update(side: BorderSide, clientX: number, clientY: number) { + const bounds = previewRef.current?.getBoundingClientRect(); + if (!bounds || bounds.width <= 0 || bounds.height <= 0) return; + const [width, height] = pixelSize.map(Math.floor) as [number, number]; + const current = draftRef.current; + let value: number; + if (side === 'left') { + value = Math.round(((clientX - bounds.left) / bounds.width) * width); + value = Math.min(Math.max(value, 0), width - current.right - 1); + } else if (side === 'right') { + value = Math.round(((bounds.right - clientX) / bounds.width) * width); + value = Math.min(Math.max(value, 0), width - current.left - 1); + } else if (side === 'top') { + value = Math.round(((clientY - bounds.top) / bounds.height) * height); + value = Math.min(Math.max(value, 0), height - current.bottom - 1); + } else { + value = Math.round(((bounds.bottom - clientY) / bounds.height) * height); + value = Math.min(Math.max(value, 0), height - current.top - 1); + } + const next = { ...current, [side]: value }; + draftRef.current = next; + setDraft(next); + } + + function start(side: BorderSide, event: PointerEvent) { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + activeRef.current = side; + setActive(side); + update(side, event.clientX, event.clientY); + } + + function move(side: BorderSide, event: PointerEvent) { + if ( + activeRef.current !== side || + !event.currentTarget.hasPointerCapture(event.pointerId) + ) { + return; + } + update(side, event.clientX, event.clientY); + } + + function finish(event: PointerEvent) { + if (!activeRef.current) return; + activeRef.current = null; + setActive(null); + event.currentTarget.releasePointerCapture(event.pointerId); + onChange(draftRef.current); + } + + function cancel() { + if (!activeRef.current) return; + activeRef.current = null; + setActive(null); + draftRef.current = border; + setDraft(border); + } + + return ( +
+
+ + + {SIDES.map((side) => ( + start(side, event)} + onPointerMove={(event) => move(side, event)} + onPointerUp={finish} + onPointerCancel={cancel} + onLostPointerCapture={cancel} + onKeyDown={(event) => { + if (event.key === 'Escape') cancel(); + }} + /> + ))} +
+
+ ); +} + +function GuideOverlay({ + border, + pixelSize: [width, height], +}: { + border: SpriteBorder; + pixelSize: [number, number]; +}) { + return ( +
+ + + + +
+ ); +} + +function Shade({ side, size }: { side: BorderSide; size: number }) { + const vertical = side === 'left' || side === 'right'; + return ( +
+ ); +} + +function GuideHandle({ + side, + border, + pixelSize, + active, + ...events +}: { + side: BorderSide; + border: SpriteBorder; + pixelSize: [number, number]; + active: boolean; +} & Pick< + React.ComponentProps<'button'>, + | 'onPointerDown' + | 'onPointerMove' + | 'onPointerUp' + | 'onPointerCancel' + | 'onLostPointerCapture' + | 'onKeyDown' +>) { + const vertical = side === 'left' || side === 'right'; + const value = border[side]; + const position = `${(value / (vertical ? pixelSize[0] : pixelSize[1])) * 100}%`; + const style = + side === 'left' + ? { left: position } + : side === 'right' + ? { right: position } + : side === 'top' + ? { top: position } + : { bottom: position }; + return ( + + ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/PrerequisiteIssues.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/PrerequisiteIssues.tsx new file mode 100644 index 000000000..f8aae330f --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/PrerequisiteIssues.tsx @@ -0,0 +1,27 @@ +import type { UiEditorPrerequisiteIssue } from '../../../features/ui-editor/prerequisites'; + +export function PrerequisiteIssues({ + issues, +}: { + issues: UiEditorPrerequisiteIssue[]; +}) { + if (issues.length === 0) { + return ( +
+ 当前 State 已满足该工具的前置数据要求。 +
+ ); + } + return ( +
+ 还不能执行此操作 +
    + {issues.map((issue, index) => ( +
  • + {issue.message} +
  • + ))} +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/PreviewWorkspace.tsx new file mode 100644 index 000000000..9226d88ae --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/PreviewWorkspace.tsx @@ -0,0 +1,66 @@ +import { Image as ImageIcon, ScanSearch } from 'lucide-react'; + +import type { UiEditorPageController } from '../useUiEditorPage'; + +export function PreviewWorkspace({ + controller, +}: { + controller: UiEditorPageController; +}) { + const { editor, activeImage, activeImageId, previewUrls } = controller; + return ( +
+
+

{controller.activeToolLabel}

+ + {Object.keys(controller.images).length} 张界面图 ·{' '} + {Object.keys(controller.sprites).length} 项素材 + +
+
+ {activeImage && previewUrls[activeImageId ?? ''] ? ( + {activeImage.metadata.name} + ) : ( +
+ +

从界面图开始

+

+ 导入同一套 UI 系统的示例图,再补充独立素材。 +

+
+ )} +
+
+ {controller.status ? ( + + {controller.status} + + ) : ( + + )} + + +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/ToolNavigation.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/ToolNavigation.tsx new file mode 100644 index 000000000..a5cf26c99 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/ToolNavigation.tsx @@ -0,0 +1,39 @@ +import { UI_EDITOR_TOOLS, type UiEditorToolId } from '../model'; + +export function ToolNavigation({ + activeTool, + onChange, +}: { + activeTool: UiEditorToolId; + onChange: (tool: UiEditorToolId) => void; +}) { + return ( + + ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx new file mode 100644 index 000000000..016c50b66 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx @@ -0,0 +1,47 @@ +import { ChevronRight, Layers3 } from 'lucide-react'; + +import type { Node as UiNode } from '../../../features/ui-editor/types/Node'; + +function TreeNodes({ nodes, depth = 0 }: { nodes: UiNode[]; depth?: number }) { + return ( +
    + {nodes.map((node, index) => ( +
  • +
    + + {node.metadata.name || '未命名节点'} + + {node.components.length} + +
    + {node.children.length > 0 ? ( + + ) : null} +
  • + ))} +
+ ); +} + +export function UiTreePanel({ nodes }: { nodes: UiNode[] | null }) { + return ( +
+
+ +

UI Tree

+
+
+ {nodes ? ( + + ) : ( +

+ 当前界面尚无组件树。 +

+ )} +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx new file mode 100644 index 000000000..7047bf0bb --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -0,0 +1,29 @@ +import { EditorDialogs } from './components/EditorDialogs'; +import { InputSidebar } from './components/InputSidebar'; +import { InspectorSidebar } from './components/Inspector/InspectorSidebar'; +import { PreviewWorkspace } from './components/PreviewWorkspace'; +import { ToolNavigation } from './components/ToolNavigation'; +import { useUiEditorPage } from './useUiEditorPage'; + +export default function UiEditorPage({ + projectPath = '/tmp/ui_editor', +}: { + projectPath?: string; +}) { + const controller = useUiEditorPage(projectPath); + + return ( +
+ +
+ + + +
+ +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/model.ts b/apps/ai-game-creator-shell/src/view/ui-editor/model.ts new file mode 100644 index 000000000..0b3062847 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/model.ts @@ -0,0 +1,53 @@ +import type { UIDesignImageRole } from '../../features/ui-editor/types/UIDesignImageRole'; +import type { RemovalImpact } from '../../features/ui-editor/useUiEditorState'; + +export type UiEditorToolId = 'input' | 'components' | 'assets' | 'layout'; +export type UiEditorImportKind = 'design-image' | 'sprite'; + +export type PendingResourceRemoval = { + kind: UiEditorImportKind; + id: string; + impact: RemovalImpact; +}; + +export const UI_EDITOR_TOOLS: Array<{ + id: UiEditorToolId; + label: string; + hint: string; +}> = [ + { id: 'input', label: '导入并确认输入', hint: '界面图与独立素材' }, + { id: 'components', label: '组件结构', hint: '识别与校正' }, + { id: 'assets', label: '素材绑定', hint: '语义与槽位' }, + { id: 'layout', label: '布局验收', hint: '生成与微调' }, +]; + +export const UI_DESIGN_IMAGE_ROLES: Array<{ + value: UIDesignImageRole; + label: string; +}> = [ + { value: 'Page', label: '主页面' }, + { value: 'Section', label: '子界面 / 页签' }, + { value: 'Modal', label: '模态弹窗' }, + { value: 'Drawer', label: '抽屉 / 侧栏' }, + { value: 'Popover', label: '局部浮层' }, + { value: 'State', label: '交互状态' }, + { value: 'Scrolled', label: '滚动后内容' }, + { value: 'Detail', label: '局部详情' }, +]; + +export function removalHasDownstreamReferences(impact: RemovalImpact) { + return ( + impact.removedTreeCount + + impact.clearedSlaveToCount + + impact.clearedTargetGraphicCount > + 0 + ); +} + +export function uiEditorOperationError(reason: string) { + if (reason === 'duplicate') return '所选资源已经存在,本批次未加入编辑器。'; + if (reason === 'limit') return '界面图最多 4 张,本批次未加入编辑器。'; + if (reason === 'locked') return '当前任务正在运行,暂时不能修改 State。'; + if (reason === 'invalid') return '资源数据无效,本批次未加入编辑器。'; + return '目标资源不存在。'; +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts new file mode 100644 index 000000000..a2833cbe6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -0,0 +1,293 @@ +import { useMemo, useState } from 'react'; + +import type { ImportedAsset } from '../../components/AssetImporter'; +import { + prepareDesignImageBatch, + prepareSpriteAssetBatch, +} from '../../features/ui-editor/importAdapter'; +import { + type UiEditorPrerequisiteIssue, + validateAssetRecognitionPrerequisites, + validateComponentRecognitionPrerequisites, + validateLayoutReviewPrerequisites, +} from '../../features/ui-editor/prerequisites'; +import type { Node as UiNode } from '../../features/ui-editor/types/Node'; +import type { SpriteAssetId } from '../../features/ui-editor/types/SpriteAssetId'; +import type { SpriteBorder } from '../../features/ui-editor/types/SpriteBorder'; +import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId'; +import type { UIDesignImageRole } from '../../features/ui-editor/types/UIDesignImageRole'; +import { + EMPTY_UI_EDITOR_STATE, + useUiEditorState, +} from '../../features/ui-editor/useUiEditorState'; +import { + type PendingResourceRemoval, + removalHasDownstreamReferences, + type UiEditorImportKind, + uiEditorOperationError, + type UiEditorToolId, +} from './model'; + +export function useUiEditorPage(projectPath: string) { + const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE); + const [activeTool, setActiveTool] = useState('input'); + const [imageOrder, setImageOrder] = useState([]); + const [activeImageId, setActiveImageId] = + useState(null); + const [selectedSpriteId, setSelectedSpriteId] = + useState(null); + const [importKind, setImportKind] = useState(null); + const [previewUrls, setPreviewUrls] = useState>({}); + const [status, setStatus] = useState(null); + const [issues, setIssues] = + useState(null); + const [clearOpen, setClearOpen] = useState(false); + const [pendingRemoval, setPendingRemoval] = + useState(null); + + const images = editor.state.ui_design_images; + const sprites = editor.state.sprite_assets; + const activeImage = activeImageId ? images[activeImageId] : null; + const selectedSprite = selectedSpriteId ? sprites[selectedSpriteId] : null; + const pageOptions = Object.entries(images).filter( + ([, image]) => image.metadata.role === 'Page', + ); + const activeToolLabel = + activeTool === 'input' + ? '导入并确认输入' + : activeTool === 'components' + ? '组件结构' + : activeTool === 'assets' + ? '素材绑定' + : '布局验收'; + + const spriteReferenceCounts = useMemo(() => { + const counts: Record = {}; + function visit(nodes: UiNode[]) { + for (const node of nodes) { + for (const component of node.components) { + if ('Image' in component && component.Image.target_graphic) { + counts[component.Image.target_graphic] = + (counts[component.Image.target_graphic] ?? 0) + 1; + } + } + visit(node.children); + } + } + for (const tree of editor.state.ui_trees) visit(tree.children); + return counts; + }, [editor.state.ui_trees]); + + const treeForActiveImage = activeImageId + ? editor.state.ui_trees.find( + (tree) => tree.src_ui_design === activeImageId, + ) + : null; + + async function importAssets(imported: ImportedAsset[]) { + if (!importKind || !projectPath) return; + setStatus(null); + try { + if (importKind === 'design-image') { + const prepared = await prepareDesignImageBatch(projectPath, imported); + const result = editor.addDesignImages( + prepared.map((item) => item.resource), + ); + if (!result.ok) { + setStatus(uiEditorOperationError(result.reason)); + return; + } + const ids = prepared.map((item) => item.resource.id); + setImageOrder((current) => [...current, ...ids]); + setActiveImageId((current) => current ?? ids[0] ?? null); + setPreviewUrls((current) => ({ + ...current, + ...Object.fromEntries( + prepared.map((item) => [item.resource.id, item.previewUrl]), + ), + })); + setStatus(`已加入 ${prepared.length} 张界面图。`); + return; + } + + const prepared = await prepareSpriteAssetBatch(projectPath, imported); + const result = editor.addSpriteAssets( + prepared.map((item) => item.resource), + ); + if (!result.ok) { + setStatus(uiEditorOperationError(result.reason)); + return; + } + setPreviewUrls((current) => ({ + ...current, + ...Object.fromEntries( + prepared.map((item) => [item.resource.asset_id, item.previewUrl]), + ), + })); + setStatus(`已加入 ${prepared.length} 项独立素材。`); + } catch (cause) { + setStatus(cause instanceof Error ? cause.message : String(cause)); + } + } + + function removeFromUiSession(id: string) { + setPreviewUrls((current) => { + const next = { ...current }; + delete next[id]; + return next; + }); + if (id === selectedSpriteId) setSelectedSpriteId(null); + if (id !== activeImageId) return; + const index = imageOrder.indexOf(id); + const remaining = imageOrder.filter((item) => item !== id); + setImageOrder(remaining); + setActiveImageId(remaining[index] ?? remaining[index - 1] ?? null); + } + + function requestDesignImageRemoval(id: UIDesignImageId) { + const result = editor.removeDesignImage(id, { dryRun: true }); + if (!result.ok) return; + if (removalHasDownstreamReferences(result.value)) { + setPendingRemoval({ kind: 'design-image', id, impact: result.value }); + return; + } + editor.removeDesignImage(id, { dryRun: false }); + removeFromUiSession(id); + } + + function requestSpriteRemoval(id: SpriteAssetId) { + const result = editor.removeSpriteAsset(id, { dryRun: true }); + if (!result.ok) return; + if (removalHasDownstreamReferences(result.value)) { + setPendingRemoval({ kind: 'sprite', id, impact: result.value }); + return; + } + editor.removeSpriteAsset(id, { dryRun: false }); + removeFromUiSession(id); + } + + function confirmRemoval() { + if (!pendingRemoval) return; + if (pendingRemoval.kind === 'design-image') { + editor.removeDesignImage(pendingRemoval.id, { dryRun: false }); + } else { + editor.removeSpriteAsset(pendingRemoval.id, { dryRun: false }); + } + removeFromUiSession(pendingRemoval.id); + setPendingRemoval(null); + } + + function checkPrerequisites() { + const validator = + activeTool === 'input' || activeTool === 'components' + ? validateComponentRecognitionPrerequisites + : activeTool === 'assets' + ? validateAssetRecognitionPrerequisites + : validateLayoutReviewPrerequisites; + setIssues(validator(editor.state)); + } + + function clearState() { + editor.clearState(); + setImageOrder([]); + setActiveImageId(null); + setSelectedSpriteId(null); + setPreviewUrls({}); + setIssues(null); + setClearOpen(false); + } + + function selectTool(tool: UiEditorToolId) { + setActiveTool(tool); + setIssues(null); + } + + function selectDesignImage(id: UIDesignImageId) { + setActiveImageId(id); + setSelectedSpriteId(null); + } + + function selectSprite(id: SpriteAssetId) { + setSelectedSpriteId(id); + } + + function setImageName(name: string) { + if (!activeImageId) return; + editor.setImageName(activeImageId, name); + setIssues(null); + } + + function setImageRole(role: UIDesignImageRole | null) { + if (!activeImageId) return; + editor.setImageRole(activeImageId, role); + setIssues(null); + } + + function setImageSlaveTo(slaveTo: UIDesignImageId | null) { + if (!activeImageId) return; + editor.setImageSlaveTo(activeImageId, slaveTo); + setIssues(null); + } + + function setSpriteName(name: string) { + if (!selectedSpriteId) return; + editor.setSpriteName(selectedSpriteId, name); + setIssues(null); + } + + function setSpriteAssetType(assetType: string) { + if (!selectedSpriteId) return; + editor.setSpriteAssetType(selectedSpriteId, assetType); + setIssues(null); + } + + function setSpriteBorder(border: SpriteBorder) { + if (!selectedSpriteId) return; + editor.setSpriteBorder(selectedSpriteId, border); + } + + return { + projectPath, + editor, + activeTool, + activeToolLabel, + imageOrder, + activeImageId, + selectedSpriteId, + importKind, + previewUrls, + status, + issues, + clearOpen, + pendingRemoval, + images, + sprites, + activeImage, + selectedSprite, + pageOptions, + spriteReferenceCounts, + treeForActiveImage, + selectTool, + selectDesignImage, + selectSprite, + openImporter: setImportKind, + closeImporter: () => setImportKind(null), + importAssets, + requestDesignImageRemoval, + requestSpriteRemoval, + confirmRemoval, + cancelRemoval: () => setPendingRemoval(null), + checkPrerequisites, + openClearDialog: () => setClearOpen(true), + closeClearDialog: () => setClearOpen(false), + clearState, + setImageName, + setImageRole, + setImageSlaveTo, + setSpriteName, + setSpriteAssetType, + setSpriteBorder, + }; +} + +export type UiEditorPageController = ReturnType; diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts new file mode 100644 index 000000000..f0287fb4e --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from '@testing-library/react'; +import { createElement, type ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/components/AssetImporter', () => ({ + AssetImporter: ({ open }: { open: boolean }) => + open ? createElement('div', { role: 'dialog' }, '素材导入器') : null, +})); + +vi.mock('../src/components/modal/ThemedModal', () => ({ + ThemedModal: ({ + open, + children, + }: { + open: boolean; + children: ReactNode; + }) => (open ? createElement('div', { role: 'dialog' }, children) : null), +})); + +import UiEditorPage from '../src/view/ui-editor'; + +describe('UiEditorPage', () => { + it('renders split input tools over a real empty State', () => { + render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' })); + + expect(screen.getByRole('navigation', { name: 'UI 编辑工具' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '导入界面图' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '导入独立素材' })).toBeTruthy(); + expect(screen.getByText('从界面图开始')).toBeTruthy(); + expect(screen.queryByText('Pause Dialog')).toBeNull(); + }); + + it('switches tools freely without inventing completed workflow state', () => { + render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' })); + + fireEvent.click(screen.getByRole('button', { name: /布局验收/ })); + expect(screen.getByRole('heading', { name: '布局验收' })).toBeTruthy(); + expect(screen.getByText('当前界面尚无组件树。')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '检查前置数据' })); + expect(screen.getByText('还不能执行此操作')).toBeTruthy(); + expect(screen.getByText('请先导入界面图')).toBeTruthy(); + }); + + it('opens the design-image importer independently', () => { + render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' })); + + fireEvent.click(screen.getByRole('button', { name: '导入界面图' })); + expect(screen.getByText('素材导入器')).toBeTruthy(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts new file mode 100644 index 000000000..ac0855d9c --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -0,0 +1,219 @@ +// @vitest-environment jsdom + +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { + validateComponentRecognitionPrerequisites, +} from '../src/features/ui-editor/prerequisites'; +import type { Node } from '../src/features/ui-editor/types/Node'; +import type { SpriteAsset } from '../src/features/ui-editor/types/SpriteAsset'; +import type { State } from '../src/features/ui-editor/types/State'; +import type { UIDesignImage } from '../src/features/ui-editor/types/UIDesignImage'; +import { + EMPTY_UI_EDITOR_STATE, + useUiEditorState, +} from '../src/features/ui-editor/useUiEditorState'; + +function image(name: string): UIDesignImage { + return { + metadata: { name, role: null, slave_to: null }, + path: `assets/${name}.png`, + pixel_size: [100, 80], + pixels_per_unit: 1, + }; +} + +function sprite(id: string): SpriteAsset { + return { + asset_id: id, + metadata: { name: id, asset_type: '' }, + path: `assets/${id}.png`, + pixel_size: [32, 32], + pixels_per_unit: 1, + border: { left: 0, right: 0, top: 0, bottom: 0 }, + }; +} + +function nodeWithSprite(id: string): Node { + return { + transform: { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [0, 0], + offset_max: [32, 32], + }, + metadata: { name: 'Image', description: '', from: '' }, + components: [ + { + Image: { + target_graphic: id, + image_type: { Simple: { preserve_aspect: false } }, + }, + }, + ], + children: [], + }; +} + +describe('useUiEditorState', () => { + it('exposes focused operations over one shared state', () => { + const { result } = renderHook(() => useUiEditorState()); + + act(() => { + expect( + result.current.addDesignImages([ + { id: 'page-a', image: image('Page A') }, + { id: 'section-a', image: image('Section A') }, + ]), + ).toEqual({ ok: true, value: undefined }); + result.current.setImageRole('page-a', 'Page'); + result.current.setImageRole('section-a', 'Section'); + result.current.setImageSlaveTo('section-a', 'page-a'); + result.current.setImageName('section-a', '任务页'); + }); + + expect(result.current.state.ui_design_images['section-a']).toMatchObject({ + metadata: { name: '任务页', role: 'Section', slave_to: 'page-a' }, + }); + expect(validateComponentRecognitionPrerequisites(result.current.state)).toEqual([]); + }); + + it('adds a batch atomically and rejects duplicates and limits', () => { + const { result } = renderHook(() => useUiEditorState()); + + act(() => { + result.current.addDesignImages([ + { id: 'a', image: image('A') }, + { id: 'b', image: image('B') }, + { id: 'c', image: image('C') }, + ]); + }); + const beforeFailure = structuredClone(result.current.state); + + act(() => { + expect( + result.current.addDesignImages([ + { id: 'd', image: image('D') }, + { id: 'e', image: image('E') }, + ]), + ).toEqual({ ok: false, reason: 'limit' }); + }); + expect(result.current.state).toEqual(beforeFailure); + + act(() => { + expect( + result.current.addDesignImages([{ id: 'a', image: image('Again') }]), + ).toEqual({ ok: false, reason: 'duplicate' }); + }); + expect(result.current.state).toEqual(beforeFailure); + + expect(result.current.state).toEqual(beforeFailure); + }); + + it('owns the async State lock and always releases it', async () => { + const { result } = renderHook(() => useUiEditorState()); + act(() => { + result.current.addDesignImages([{ id: 'a', image: image('A') }]); + }); + + let release: (() => void) | undefined; + const waitForRelease = new Promise((resolve) => { + release = resolve; + }); + let operation: Promise | undefined; + + await act(async () => { + operation = result.current.runWithStateLocked(async (snapshot) => { + expect(snapshot).toEqual(result.current.state); + expect(snapshot).not.toBe(result.current.state); + await waitForRelease; + return 'recognized'; + }); + await Promise.resolve(); + }); + + expect(result.current.isLocked).toBe(true); + act(() => { + expect(result.current.setImageName('a', 'Locked')).toEqual({ + ok: false, + reason: 'locked', + }); + }); + await expect( + result.current.runWithStateLocked(async () => undefined), + ).rejects.toThrow('UI editor State is already locked'); + + await act(async () => { + release?.(); + await operation; + }); + expect(result.current.isLocked).toBe(false); + + await act(async () => { + await expect( + result.current.runWithStateLocked(async () => { + throw new Error('recognition failed'); + }), + ).rejects.toThrow('recognition failed'); + }); + expect(result.current.isLocked).toBe(false); + }); + + it('dry-runs and then fully cleans referenced resources', () => { + const initial: State = { + ...structuredClone(EMPTY_UI_EDITOR_STATE), + ui_design_images: { + page: { ...image('Page'), metadata: { name: 'Page', role: 'Page', slave_to: null } }, + child: { ...image('Child'), metadata: { name: 'Child', role: 'Section', slave_to: 'page' } }, + }, + sprite_assets: { panel: sprite('panel') }, + ui_trees: [ + { src_ui_design: 'page', children: [nodeWithSprite('panel')] }, + ], + }; + const { result } = renderHook(() => useUiEditorState(initial)); + + act(() => { + expect(result.current.removeSpriteAsset('panel', { dryRun: true })).toEqual({ + ok: true, + value: { + removedResourceCount: 1, + removedTreeCount: 0, + clearedSlaveToCount: 0, + clearedTargetGraphicCount: 1, + }, + }); + }); + expect(result.current.state.sprite_assets.panel).toBeDefined(); + + act(() => { + result.current.removeSpriteAsset('panel', { dryRun: false }); + 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_design_images.page).toBeUndefined(); + expect(result.current.state.ui_design_images.child?.metadata.slave_to).toBeNull(); + }); + + it('keeps setters local and defers workflow errors to prerequisites', () => { + const initial: State = { + ...structuredClone(EMPTY_UI_EDITOR_STATE), + ui_design_images: { + page: { ...image('Page'), metadata: { name: 'Page', role: 'Page', slave_to: null } }, + child: { ...image('Child'), metadata: { name: 'Child', role: 'Section', slave_to: 'page' } }, + }, + }; + const { result } = renderHook(() => useUiEditorState(initial)); + + act(() => { + result.current.setImageRole('page', null); + }); + + expect(result.current.state.ui_design_images.child?.metadata.slave_to).toBe('page'); + expect(validateComponentRecognitionPrerequisites(result.current.state)).toEqual([ + expect.objectContaining({ code: 'invalid-slave-to', resourceId: 'child' }), + ]); + }); +});