mirror of
https://gitee.com/dlmu-cone/Arborflow
synced 2026-10-23 17:52:20 +08:00
fix: support multi-tree BehaviorTree XML imports
This commit is contained in:
15
README.md
15
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
ArborFlow 是一个面向 [BehaviorTree.CPP](https://github.com/BehaviorTree/BehaviorTree.CPP) 的跨平台行为树生成、编辑与运行状态可视化工具。
|
||||
|
||||
项目使用 Electron、React、TypeScript 和 React Flow 构建,可在 Windows、Linux 与 macOS 上运行。当前版本以单个主行为树为编辑单位,可通过 `SubTree` 节点引用其他行为树。
|
||||
项目使用 Electron、React、TypeScript 和 React Flow 构建,可在 Windows、Linux 与 macOS 上运行。一个工程可以包含多棵行为树,并通过 `SubTree` 节点相互引用。
|
||||
|
||||
## 主要功能
|
||||
|
||||
@@ -13,6 +13,8 @@ ArborFlow 是一个面向 [BehaviorTree.CPP](https://github.com/BehaviorTree/Beh
|
||||
- 自动布局、画布缩放、小地图、撤销与重做
|
||||
- 保存和打开 `.arborflow` 工程文件
|
||||
- 导入、导出和预览 BehaviorTree.CPP v4 XML
|
||||
- 导入多棵 `BehaviorTree` 并在顶部树选择器中切换
|
||||
- 解析 `TreeNodesModel`,加载自定义节点及其端口默认值
|
||||
- 通过 WebSocket 或 `rosbridge_server` 实时显示节点状态
|
||||
- 实时事件流、消息计数、节点匹配统计、延迟检测和自动重连
|
||||
|
||||
@@ -65,10 +67,11 @@ npm run preview
|
||||
2. 从父节点底部连接点拖到子节点顶部连接点。
|
||||
3. 选中节点后,在右侧检查器修改名称、端口、黑板参数或断点。
|
||||
4. 使用工具栏的自动布局按钮整理整棵树。
|
||||
5. 将工程保存为 `.arborflow`,或导出为 BehaviorTree.CPP XML。
|
||||
6. 打开 Monitor,填写机器人地址后连接实时状态流。
|
||||
5. 多树工程可通过画布左上方的树选择器切换当前行为树。
|
||||
6. 将工程保存为 `.arborflow`,或导出为 BehaviorTree.CPP XML。
|
||||
7. 打开 Monitor,填写机器人地址后连接实时状态流。
|
||||
|
||||
工程文件保存编辑器坐标、节点 ID、端口、备注等完整信息。XML 只保存 BehaviorTree.CPP 可执行数据,不包含编辑器坐标或实时运行状态。
|
||||
工程文件保存全部行为树、编辑器坐标、节点 ID、节点模型、端口和备注等完整信息。XML 不包含编辑器坐标或实时运行状态。
|
||||
|
||||
## BehaviorTree.CPP XML
|
||||
|
||||
@@ -88,6 +91,10 @@ ArborFlow 导出 BehaviorTree.CPP v4 格式:
|
||||
|
||||
同一父节点下的执行顺序按照子节点在画布上的横向位置从左到右确定。导出前必须保证画布中只有一个根节点,并且所有节点组成一棵无环树。
|
||||
|
||||
XML 没有声明 `main_tree_to_execute` 时,ArborFlow 会分析所有 `SubTree` 引用,将没有被其他树引用的行为树推断为主树。存在多个候选时,优先使用 `MainTree`,否则使用 XML 中最后一棵树。
|
||||
|
||||
`TreeNodesModel` 中的自定义 Action、Condition、Decorator 和 Control 会进入左侧的“XML 自定义节点”分组。端口名称、方向、类型、默认值和描述会在工程及再次导出的 XML 中保留。
|
||||
|
||||
## Real-time Monitor
|
||||
|
||||
监视器支持两种接入方式:
|
||||
|
||||
62
src/App.tsx
62
src/App.tsx
@@ -37,10 +37,11 @@ import { BehaviorNodeCard } from './components/BehaviorNodeCard'
|
||||
import { Inspector } from './components/Inspector'
|
||||
import { MonitorPanel } from './components/MonitorPanel'
|
||||
import { NodePalette } from './components/NodePalette'
|
||||
import { createNode, maxChildren } from './lib/catalog'
|
||||
import { createNode, createNodeFromModel, maxChildren } from './lib/catalog'
|
||||
import { createDemoDocument, createEmptyDocument } from './lib/demo'
|
||||
import { layoutDocument } from './lib/layout'
|
||||
import { downloadText, parseProject, pickBrowserFile, serializeProject } from './lib/project'
|
||||
import { activeTreeId, switchDocumentTree, syncedTreeGraphs } from './lib/trees'
|
||||
import { validateDocument, wouldCreateCycle } from './lib/validation'
|
||||
import { documentToXml, xmlToDocument } from './lib/xml'
|
||||
import type { BehaviorNode, BehaviorTreeDocument, NodeType, RuntimeStatus } from './types'
|
||||
@@ -57,6 +58,7 @@ function Editor() {
|
||||
const [projectPath, setProjectPath] = useState<string | null>(null)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('canvas')
|
||||
const [treeMenuOpen, setTreeMenuOpen] = useState(false)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
||||
const [monitorOpen, setMonitorOpen] = useState(false)
|
||||
@@ -74,6 +76,8 @@ function Editor() {
|
||||
() => treeDocument.nodes.find((node) => node.id === selectedNodeId) || null,
|
||||
[treeDocument.nodes, selectedNodeId],
|
||||
)
|
||||
const treeGraphs = useMemo(() => syncedTreeGraphs(treeDocument), [treeDocument])
|
||||
const currentTreeId = activeTreeId(treeDocument)
|
||||
const issues = useMemo(() => validateDocument(treeDocument), [treeDocument])
|
||||
|
||||
const xmlPreview = useMemo(() => {
|
||||
@@ -149,6 +153,32 @@ function Editor() {
|
||||
setViewMode('canvas')
|
||||
}, [commit, screenToFlowPosition])
|
||||
|
||||
const addCustomNodeAt = useCallback((modelId: string, position?: { x: number; y: number }) => {
|
||||
const model = treeDocument.nodeModels?.find((item) => item.id === modelId)
|
||||
if (!model) return
|
||||
const point = position || screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 })
|
||||
const node = createNodeFromModel(model, point)
|
||||
commit((current) => ({ ...current, nodes: [...current.nodes, node] }))
|
||||
setSelectedNodeId(node.id)
|
||||
setViewMode('canvas')
|
||||
}, [commit, screenToFlowPosition, treeDocument.nodeModels])
|
||||
|
||||
const switchTree = useCallback((treeId: string) => {
|
||||
if (treeId === currentTreeId) {
|
||||
setTreeMenuOpen(false)
|
||||
return
|
||||
}
|
||||
setTreeDocument((current) => switchDocumentTree(current, treeId))
|
||||
setSelectedNodeId(null)
|
||||
setSelectedEdgeId(null)
|
||||
setRuntimeStatuses({})
|
||||
setTreeMenuOpen(false)
|
||||
pastRef.current = []
|
||||
futureRef.current = []
|
||||
setHistoryRevision((value) => value + 1)
|
||||
window.setTimeout(() => fitView({ padding: 0.2, duration: 320 }), 60)
|
||||
}, [currentTreeId, fitView])
|
||||
|
||||
const deleteNode = useCallback((nodeId: string) => {
|
||||
commit((current) => ({
|
||||
...current,
|
||||
@@ -292,10 +322,15 @@ function Editor() {
|
||||
|
||||
const handleDrop = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault()
|
||||
const modelId = event.dataTransfer.getData('application/arborflow-model')
|
||||
if (modelId) {
|
||||
addCustomNodeAt(modelId, screenToFlowPosition({ x: event.clientX, y: event.clientY }))
|
||||
return
|
||||
}
|
||||
const type = event.dataTransfer.getData('application/arborflow-node') as NodeType
|
||||
if (!type) return
|
||||
addNodeAt(type, screenToFlowPosition({ x: event.clientX, y: event.clientY }))
|
||||
}, [addNodeAt, screenToFlowPosition])
|
||||
}, [addCustomNodeAt, addNodeAt, screenToFlowPosition])
|
||||
|
||||
const copyXml = useCallback(async () => {
|
||||
if (!xmlPreview.value) return
|
||||
@@ -415,7 +450,22 @@ function Editor() {
|
||||
</header>
|
||||
|
||||
<div className="workspace-toolbar">
|
||||
<div className="tree-tab"><GitBranch size={15} /><span>{treeDocument.mainTreeId}</span><ChevronDown size={13} /></div>
|
||||
<div className="tree-selector">
|
||||
<button className={`tree-tab ${treeMenuOpen ? 'active' : ''}`} onClick={() => setTreeMenuOpen((value) => !value)}>
|
||||
<GitBranch size={15} /><span>{currentTreeId}</span><ChevronDown size={13} />
|
||||
</button>
|
||||
{treeMenuOpen && (
|
||||
<div className="tree-menu">
|
||||
<div className="tree-menu-heading">行为树 · {treeGraphs.length}</div>
|
||||
{treeGraphs.map((tree) => (
|
||||
<button className={tree.id === currentTreeId ? 'active' : ''} key={tree.id} onClick={() => switchTree(tree.id)}>
|
||||
<span>{tree.id}</span>
|
||||
<small>{tree.nodes.length} 节点{tree.id === treeDocument.mainTreeId ? ' · MAIN' : ''}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="view-switch" aria-label="视图切换">
|
||||
<button className={viewMode === 'canvas' ? 'active' : ''} onClick={() => setViewMode('canvas')}><TreePine size={14} />画布</button>
|
||||
<button className={viewMode === 'xml' ? 'active' : ''} onClick={() => setViewMode('xml')}><Code2 size={14} />XML</button>
|
||||
@@ -426,7 +476,7 @@ function Editor() {
|
||||
</div>
|
||||
|
||||
<main className="workspace-grid">
|
||||
<NodePalette onAddNode={addNodeAt} />
|
||||
<NodePalette customModels={treeDocument.nodeModels || []} onAddNode={addNodeAt} onAddCustomNode={addCustomNodeAt} />
|
||||
<section className="center-stage">
|
||||
<div className="canvas-region" onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'copy' }} onDrop={handleDrop}>
|
||||
{viewMode === 'canvas' ? (
|
||||
@@ -438,7 +488,7 @@ function Editor() {
|
||||
onConnect={connectNodes}
|
||||
onNodeClick={(_event, node) => { setSelectedNodeId(node.id); setSelectedEdgeId(null) }}
|
||||
onEdgeClick={(_event, edge) => { setSelectedEdgeId(edge.id); setSelectedNodeId(null) }}
|
||||
onPaneClick={() => { setSelectedNodeId(null); setSelectedEdgeId(null) }}
|
||||
onPaneClick={() => { setSelectedNodeId(null); setSelectedEdgeId(null); setTreeMenuOpen(false) }}
|
||||
onNodeDragStart={() => { dragStartRef.current = treeDocument }}
|
||||
onNodeDragStop={() => {
|
||||
if (dragStartRef.current) {
|
||||
@@ -508,7 +558,7 @@ function Editor() {
|
||||
|
||||
<footer className="status-bar">
|
||||
<div><span className={errors ? 'status-error' : 'status-valid'}>{errors ? `${errors} 个结构错误` : <><Check size={12} />结构有效</>}</span></div>
|
||||
<div><span>{treeDocument.nodes.length} 节点</span><span>{treeDocument.edges.length} 连接</span><span>{zoom}%</span><span>{window.arborflow ? `Desktop · ${window.arborflow.platform}` : 'Web Preview'}</span></div>
|
||||
<div><span>{treeGraphs.length} 棵树</span><span>{treeDocument.nodes.length} 节点</span><span>{treeDocument.edges.length} 连接</span><span>{zoom}%</span><span>{window.arborflow ? `Desktop · ${window.arborflow.platform}` : 'Web Preview'}</span></div>
|
||||
</footer>
|
||||
{toast && <div className={`toast ${toast.kind}`}>{toast.message}</div>}
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,40 @@
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { Search, SquareFunction, X, Zap } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { CATEGORY_LABELS, NODE_DEFINITIONS } from '../lib/catalog'
|
||||
import type { NodeCategory, NodeType } from '../types'
|
||||
import type { CustomNodeModel, NodeCategory, NodeType } from '../types'
|
||||
|
||||
interface NodePaletteProps {
|
||||
customModels: CustomNodeModel[]
|
||||
onAddNode: (type: NodeType) => void
|
||||
onAddCustomNode: (modelId: string) => void
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER: NodeCategory[] = ['control', 'decorator', 'action', 'condition', 'subtree']
|
||||
|
||||
export function NodePalette({ onAddNode }: NodePaletteProps) {
|
||||
export function NodePalette({ customModels, onAddNode, onAddCustomNode }: NodePaletteProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const groups = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
return CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
items: NODE_DEFINITIONS.filter((item) => item.category === category)
|
||||
.filter((item) => !normalized || `${item.label} ${item.description} ${item.type}`.toLowerCase().includes(normalized)),
|
||||
})).filter((group) => group.items.length)
|
||||
}, [query])
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
const groups = useMemo(() => CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
items: NODE_DEFINITIONS.filter((item) => item.category === category)
|
||||
.filter((item) => !normalizedQuery || `${item.label} ${item.description} ${item.type}`.toLowerCase().includes(normalizedQuery)),
|
||||
})).filter((group) => group.items.length), [normalizedQuery])
|
||||
|
||||
const visibleModels = useMemo(() => customModels.filter((model) => {
|
||||
if (!normalizedQuery) return true
|
||||
return `${model.id} ${model.category} ${model.ports.map((port) => port.name).join(' ')}`.toLowerCase().includes(normalizedQuery)
|
||||
}), [customModels, normalizedQuery])
|
||||
|
||||
const beginDrag = (event: React.DragEvent, type: NodeType) => {
|
||||
event.dataTransfer.setData('application/arborflow-node', type)
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
const beginCustomDrag = (event: React.DragEvent, modelId: string) => {
|
||||
event.dataTransfer.setData('application/arborflow-model', modelId)
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="left-panel">
|
||||
<div className="panel-heading">
|
||||
@@ -32,14 +42,37 @@ export function NodePalette({ onAddNode }: NodePaletteProps) {
|
||||
<span className="eyebrow">LIBRARY</span>
|
||||
<h2>节点</h2>
|
||||
</div>
|
||||
<span className="count-label">{NODE_DEFINITIONS.length}</span>
|
||||
<span className="count-label">{NODE_DEFINITIONS.length + customModels.length}</span>
|
||||
</div>
|
||||
<div className="palette-search">
|
||||
<Search size={15} />
|
||||
<Search size={16} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索节点" aria-label="搜索节点" />
|
||||
{query && <button className="bare-icon" onClick={() => setQuery('')} title="清除搜索"><X size={14} /></button>}
|
||||
{query && <button className="bare-icon" onClick={() => setQuery('')} title="清除搜索"><X size={15} /></button>}
|
||||
</div>
|
||||
<div className="palette-scroll">
|
||||
{visibleModels.length > 0 && (
|
||||
<section className="palette-group custom-model-group">
|
||||
<h3>XML 自定义节点</h3>
|
||||
<div className="palette-items">
|
||||
{visibleModels.map((model) => {
|
||||
const Icon = model.category === 'condition' ? SquareFunction : Zap
|
||||
return (
|
||||
<button
|
||||
className={`palette-item category-${model.category}`}
|
||||
draggable
|
||||
key={model.id}
|
||||
onDragStart={(event) => beginCustomDrag(event, model.id)}
|
||||
onClick={() => onAddCustomNode(model.id)}
|
||||
title={`${CATEGORY_LABELS[model.category]} · ${model.ports.length} 个端口`}
|
||||
>
|
||||
<span className="palette-item-icon"><Icon size={16} /></span>
|
||||
<span className="palette-item-name">{model.id}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{groups.map(({ category, items }) => (
|
||||
<section className="palette-group" key={category}>
|
||||
<h3>{CATEGORY_LABELS[category]}</h3>
|
||||
@@ -63,7 +96,7 @@ export function NodePalette({ onAddNode }: NodePaletteProps) {
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{!groups.length && <div className="panel-empty">没有匹配节点</div>}
|
||||
{!groups.length && !visibleModels.length && <div className="panel-empty">没有匹配节点</div>}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type { BehaviorNode, NodeCategory, NodeType } from '../types'
|
||||
import type { BehaviorNode, CustomNodeModel, NodeCategory, NodeType } from '../types'
|
||||
|
||||
export interface NodeDefinition {
|
||||
type: NodeType
|
||||
@@ -70,6 +70,19 @@ export function createNode(type: NodeType, position = { x: 80, y: 80 }): Behavio
|
||||
}
|
||||
}
|
||||
|
||||
export function createNodeFromModel(model: CustomNodeModel, position = { x: 80, y: 80 }): BehaviorNode {
|
||||
const node = createNode(model.nodeType, position)
|
||||
node.data = {
|
||||
...node.data,
|
||||
label: model.id,
|
||||
category: model.category,
|
||||
registrationName: model.id,
|
||||
xmlTag: model.id,
|
||||
ports: Object.fromEntries(model.ports.map((port) => [port.name, port.defaultValue || ''])),
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
export function canHaveChildren(type: NodeType) {
|
||||
return !['Action', 'Condition', 'SubTree', 'AlwaysSuccess', 'AlwaysFailure'].includes(type)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { BehaviorTreeDocument } from '../types'
|
||||
const NODE_WIDTH = 210
|
||||
const NODE_HEIGHT = 84
|
||||
|
||||
export function layoutDocument(document: BehaviorTreeDocument): BehaviorTreeDocument {
|
||||
export function layoutGraph<T extends Pick<BehaviorTreeDocument, 'nodes' | 'edges'>>(document: T): T {
|
||||
const graph = new dagre.graphlib.Graph()
|
||||
graph.setDefaultEdgeLabel(() => ({}))
|
||||
graph.setGraph({ rankdir: 'TB', nodesep: 46, ranksep: 88, marginx: 40, marginy: 40 })
|
||||
@@ -24,3 +24,7 @@ export function layoutDocument(document: BehaviorTreeDocument): BehaviorTreeDocu
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function layoutDocument(document: BehaviorTreeDocument): BehaviorTreeDocument {
|
||||
return layoutGraph(document)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { syncActiveTree, switchDocumentTree } from './trees'
|
||||
import type { BehaviorTreeDocument } from '../types'
|
||||
|
||||
export function serializeProject(document: BehaviorTreeDocument) {
|
||||
return JSON.stringify(document, null, 2) + '\n'
|
||||
return JSON.stringify(syncActiveTree(document), null, 2) + '\n'
|
||||
}
|
||||
|
||||
export function parseProject(content: string): BehaviorTreeDocument {
|
||||
@@ -14,7 +15,9 @@ export function parseProject(content: string): BehaviorTreeDocument {
|
||||
if (!Array.isArray(candidate.nodes) || !Array.isArray(candidate.edges)) {
|
||||
throw new Error('工程文件缺少节点或连接数据')
|
||||
}
|
||||
return candidate as BehaviorTreeDocument
|
||||
const document = candidate as BehaviorTreeDocument
|
||||
const requestedTree = document.activeTreeId || document.mainTreeId
|
||||
return document.trees?.length ? switchDocumentTree(document, requestedTree) : document
|
||||
}
|
||||
|
||||
export function downloadText(filename: string, content: string, type = 'text/plain') {
|
||||
|
||||
39
src/lib/trees.ts
Normal file
39
src/lib/trees.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { BehaviorTreeDocument, BehaviorTreeGraph } from '../types'
|
||||
|
||||
export function activeTreeId(document: BehaviorTreeDocument) {
|
||||
return document.activeTreeId || document.mainTreeId
|
||||
}
|
||||
|
||||
export function syncedTreeGraphs(document: BehaviorTreeDocument): BehaviorTreeGraph[] {
|
||||
const currentId = activeTreeId(document)
|
||||
const currentGraph: BehaviorTreeGraph = {
|
||||
id: currentId,
|
||||
nodes: document.nodes,
|
||||
edges: document.edges,
|
||||
}
|
||||
if (!document.trees?.length) return [currentGraph]
|
||||
|
||||
let replaced = false
|
||||
const trees = document.trees.map((tree) => {
|
||||
if (tree.id !== currentId) return tree
|
||||
replaced = true
|
||||
return currentGraph
|
||||
})
|
||||
return replaced ? trees : [...trees, currentGraph]
|
||||
}
|
||||
|
||||
export function syncActiveTree(document: BehaviorTreeDocument): BehaviorTreeDocument {
|
||||
return { ...document, activeTreeId: activeTreeId(document), trees: syncedTreeGraphs(document) }
|
||||
}
|
||||
|
||||
export function switchDocumentTree(document: BehaviorTreeDocument, treeId: string): BehaviorTreeDocument {
|
||||
const synced = syncActiveTree(document)
|
||||
const target = synced.trees?.find((tree) => tree.id === treeId)
|
||||
if (!target) return document
|
||||
return {
|
||||
...synced,
|
||||
activeTreeId: treeId,
|
||||
nodes: target.nodes,
|
||||
edges: target.edges,
|
||||
}
|
||||
}
|
||||
223
src/lib/xml.ts
223
src/lib/xml.ts
@@ -1,12 +1,24 @@
|
||||
import { createNode, NODE_DEFINITION_MAP } from './catalog'
|
||||
import { layoutDocument } from './layout'
|
||||
import type { BehaviorEdge, BehaviorNode, BehaviorTreeDocument, NodeType } from '../types'
|
||||
import { layoutGraph } from './layout'
|
||||
import { syncedTreeGraphs } from './trees'
|
||||
import type {
|
||||
BehaviorEdge,
|
||||
BehaviorNode,
|
||||
BehaviorTreeDocument,
|
||||
BehaviorTreeGraph,
|
||||
CustomNodeModel,
|
||||
NodeCategory,
|
||||
NodePortModel,
|
||||
NodeType,
|
||||
} from '../types'
|
||||
|
||||
const CONTROL_AND_DECORATOR_TYPES = new Set<NodeType>([
|
||||
'Sequence', 'Fallback', 'Parallel', 'ReactiveSequence', 'ReactiveFallback',
|
||||
'Inverter', 'RetryUntilSuccessful', 'Repeat', 'ForceSuccess', 'ForceFailure',
|
||||
])
|
||||
|
||||
const PORT_TAGS = new Set<NodePortModel['direction']>(['input_port', 'output_port', 'inout_port'])
|
||||
|
||||
function escapeXml(value: string) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
@@ -17,22 +29,23 @@ function escapeXml(value: string) {
|
||||
}
|
||||
|
||||
function nodeTag(node: BehaviorNode) {
|
||||
if (node.data.xmlTag) return node.data.xmlTag
|
||||
if (node.data.nodeType === 'SubTree') return 'SubTree'
|
||||
if (node.data.nodeType === 'Action') return 'Action'
|
||||
if (node.data.nodeType === 'Condition') return 'Condition'
|
||||
return node.data.nodeType
|
||||
}
|
||||
|
||||
export function documentToXml(document: BehaviorTreeDocument) {
|
||||
if (document.nodes.length === 0) throw new Error('画布为空,无法导出 XML')
|
||||
const incoming = new Map(document.nodes.map((node) => [node.id, 0]))
|
||||
document.edges.forEach((edge) => incoming.set(edge.target, (incoming.get(edge.target) || 0) + 1))
|
||||
const roots = document.nodes.filter((node) => (incoming.get(node.id) || 0) === 0)
|
||||
if (roots.length !== 1) throw new Error(`XML 只能导出一棵完整树,当前检测到 ${roots.length} 个根节点`)
|
||||
function renderTree(graph: BehaviorTreeGraph) {
|
||||
if (graph.nodes.length === 0) throw new Error(`${graph.id} 画布为空,无法导出 XML`)
|
||||
const incoming = new Map(graph.nodes.map((node) => [node.id, 0]))
|
||||
graph.edges.forEach((edge) => incoming.set(edge.target, (incoming.get(edge.target) || 0) + 1))
|
||||
const roots = graph.nodes.filter((node) => (incoming.get(node.id) || 0) === 0)
|
||||
if (roots.length !== 1) throw new Error(`${graph.id} 必须只有一个根节点,当前检测到 ${roots.length} 个`)
|
||||
|
||||
const byId = new Map(document.nodes.map((node) => [node.id, node]))
|
||||
const byId = new Map(graph.nodes.map((node) => [node.id, node]))
|
||||
const children = new Map<string, BehaviorNode[]>()
|
||||
document.edges.forEach((edge) => {
|
||||
graph.edges.forEach((edge) => {
|
||||
const child = byId.get(edge.target)
|
||||
if (child) children.set(edge.source, [...(children.get(edge.source) || []), child])
|
||||
})
|
||||
@@ -40,12 +53,12 @@ export function documentToXml(document: BehaviorTreeDocument) {
|
||||
|
||||
const visited = new Set<string>()
|
||||
const renderNode = (node: BehaviorNode, depth: number): string => {
|
||||
if (visited.has(node.id)) throw new Error('树中存在重复引用或环路,无法导出 XML')
|
||||
if (visited.has(node.id)) throw new Error(`${graph.id} 中存在重复引用或环路`)
|
||||
visited.add(node.id)
|
||||
const indent = ' '.repeat(depth)
|
||||
const tag = nodeTag(node)
|
||||
const attributes: Array<[string, string]> = []
|
||||
if (['Action', 'Condition', 'SubTree'].includes(tag)) attributes.push(['ID', node.data.registrationName])
|
||||
if (['Action', 'Condition', 'SubTree', 'SubTreePlus'].includes(tag)) attributes.push(['ID', node.data.registrationName])
|
||||
if (node.data.label !== node.data.registrationName && node.data.label !== node.data.nodeType) attributes.push(['name', node.data.label])
|
||||
Object.entries(node.data.ports).forEach(([key, value]) => {
|
||||
if (key.trim()) attributes.push([key.trim(), value])
|
||||
@@ -57,58 +70,162 @@ export function documentToXml(document: BehaviorTreeDocument) {
|
||||
return `${indent}<${tag}${attrs}>\n${content}\n${indent}</${tag}>`
|
||||
}
|
||||
|
||||
const treeXml = renderNode(roots[0], 2)
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<root BTCPP_format="4" main_tree_to_execute="${escapeXml(document.mainTreeId)}">\n <BehaviorTree ID="${escapeXml(document.mainTreeId)}">\n${treeXml}\n </BehaviorTree>\n</root>\n`
|
||||
return ` <BehaviorTree ID="${escapeXml(graph.id)}">\n${renderNode(roots[0], 2)}\n </BehaviorTree>`
|
||||
}
|
||||
|
||||
function elementType(element: Element): NodeType {
|
||||
function renderNodeModels(models: CustomNodeModel[]) {
|
||||
if (!models.length) return ''
|
||||
const content = models.map((model) => {
|
||||
const tag = model.category === 'condition' ? 'Condition' : model.category === 'decorator' ? 'Decorator' : model.category === 'control' ? 'Control' : 'Action'
|
||||
const ports = model.ports.map((port) => {
|
||||
const defaultAttribute = port.defaultValue === undefined ? '' : ` default="${escapeXml(port.defaultValue)}"`
|
||||
const typeAttribute = port.type ? ` type="${escapeXml(port.type)}"` : ''
|
||||
return ` <${port.direction} name="${escapeXml(port.name)}"${defaultAttribute}${typeAttribute}>${escapeXml(port.description)}</${port.direction}>`
|
||||
}).join('\n')
|
||||
return ports ? ` <${tag} ID="${escapeXml(model.id)}">\n${ports}\n </${tag}>` : ` <${tag} ID="${escapeXml(model.id)}"/>`
|
||||
}).join('\n')
|
||||
return `\n <TreeNodesModel>\n${content}\n </TreeNodesModel>`
|
||||
}
|
||||
|
||||
export function documentToXml(document: BehaviorTreeDocument) {
|
||||
const trees = syncedTreeGraphs(document)
|
||||
const treeXml = trees.map(renderTree).join('\n\n')
|
||||
const modelsXml = renderNodeModels(document.nodeModels || [])
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<root BTCPP_format="4" main_tree_to_execute="${escapeXml(document.mainTreeId)}">\n${treeXml}${modelsXml}\n</root>\n`
|
||||
}
|
||||
|
||||
function categoryForModelTag(tag: string): NodeCategory {
|
||||
if (tag === 'Condition') return 'condition'
|
||||
if (tag === 'Decorator') return 'decorator'
|
||||
if (tag === 'Control') return 'control'
|
||||
if (tag === 'SubTree') return 'subtree'
|
||||
return 'action'
|
||||
}
|
||||
|
||||
function nodeTypeForCategory(category: NodeCategory): NodeType {
|
||||
if (category === 'condition') return 'Condition'
|
||||
if (category === 'decorator') return 'Repeat'
|
||||
if (category === 'control') return 'Sequence'
|
||||
if (category === 'subtree') return 'SubTree'
|
||||
return 'Action'
|
||||
}
|
||||
|
||||
function parseNodeModels(root: Element): CustomNodeModel[] {
|
||||
const container = Array.from(root.children).find((child) => child.tagName === 'TreeNodesModel')
|
||||
if (!container) return []
|
||||
return Array.from(container.children).flatMap((element) => {
|
||||
const id = element.getAttribute('ID')
|
||||
if (!id) return []
|
||||
const category = categoryForModelTag(element.tagName)
|
||||
const ports = Array.from(element.children).flatMap((portElement) => {
|
||||
if (!PORT_TAGS.has(portElement.tagName as NodePortModel['direction'])) return []
|
||||
const name = portElement.getAttribute('name')
|
||||
if (!name) return []
|
||||
const port: NodePortModel = {
|
||||
name,
|
||||
direction: portElement.tagName as NodePortModel['direction'],
|
||||
type: portElement.getAttribute('type') || '',
|
||||
description: portElement.textContent?.trim() || '',
|
||||
}
|
||||
if (portElement.hasAttribute('default')) port.defaultValue = portElement.getAttribute('default') || ''
|
||||
return [port]
|
||||
})
|
||||
return [{ id, category, nodeType: nodeTypeForCategory(category), ports }]
|
||||
})
|
||||
}
|
||||
|
||||
function resolveElementType(element: Element, models: Map<string, CustomNodeModel>) {
|
||||
const tag = element.tagName
|
||||
if (NODE_DEFINITION_MAP.has(tag as NodeType)) return tag as NodeType
|
||||
if (tag === 'SubTreePlus') return 'SubTree'
|
||||
const children = Array.from(element.children)
|
||||
return children.length ? 'Sequence' : 'Action'
|
||||
if (NODE_DEFINITION_MAP.has(tag as NodeType)) {
|
||||
const definition = NODE_DEFINITION_MAP.get(tag as NodeType)!
|
||||
return { nodeType: tag as NodeType, category: definition.category }
|
||||
}
|
||||
if (tag === 'SubTreePlus') return { nodeType: 'SubTree' as NodeType, category: 'subtree' as NodeCategory }
|
||||
if (tag === 'Action') return { nodeType: 'Action' as NodeType, category: 'action' as NodeCategory }
|
||||
if (tag === 'Condition') return { nodeType: 'Condition' as NodeType, category: 'condition' as NodeCategory }
|
||||
const model = models.get(tag)
|
||||
if (model) return { nodeType: model.nodeType, category: model.category }
|
||||
const childCount = element.children.length
|
||||
if (childCount === 1) return { nodeType: 'Repeat' as NodeType, category: 'decorator' as NodeCategory }
|
||||
if (childCount > 1) return { nodeType: 'Sequence' as NodeType, category: 'control' as NodeCategory }
|
||||
return { nodeType: 'Action' as NodeType, category: 'action' as NodeCategory }
|
||||
}
|
||||
|
||||
function parseBehaviorTree(element: Element, models: Map<string, CustomNodeModel>): BehaviorTreeGraph {
|
||||
const treeId = element.getAttribute('ID') || 'MainTree'
|
||||
const treeRoot = Array.from(element.children)[0]
|
||||
if (!treeRoot) return { id: treeId, nodes: [], edges: [] }
|
||||
|
||||
const nodes: BehaviorNode[] = []
|
||||
const edges: BehaviorEdge[] = []
|
||||
let edgeIndex = 0
|
||||
|
||||
const visit = (nodeElement: Element, parentId?: string) => {
|
||||
const { nodeType, category } = resolveElementType(nodeElement, models)
|
||||
const node = createNode(nodeType)
|
||||
const explicitId = nodeElement.getAttribute('ID')
|
||||
const registrationName = explicitId || (CONTROL_AND_DECORATOR_TYPES.has(nodeType) && NODE_DEFINITION_MAP.has(nodeElement.tagName as NodeType)
|
||||
? nodeElement.tagName
|
||||
: nodeElement.tagName)
|
||||
const model = models.get(registrationName) || models.get(nodeElement.tagName)
|
||||
const ports: Record<string, string> = model
|
||||
? Object.fromEntries(model.ports.map((port) => [port.name, port.defaultValue || '']))
|
||||
: {}
|
||||
Array.from(nodeElement.attributes).forEach((attribute) => {
|
||||
if (!['ID', 'name'].includes(attribute.name)) ports[attribute.name] = attribute.value
|
||||
})
|
||||
node.data = {
|
||||
...node.data,
|
||||
label: nodeElement.getAttribute('name') || registrationName,
|
||||
registrationName,
|
||||
category,
|
||||
ports,
|
||||
xmlTag: nodeElement.tagName,
|
||||
}
|
||||
nodes.push(node)
|
||||
if (parentId) edges.push({ id: `edge_${treeId}_${edgeIndex++}`, source: parentId, target: node.id, type: 'smoothstep' })
|
||||
Array.from(nodeElement.children).forEach((child) => visit(child, node.id))
|
||||
}
|
||||
visit(treeRoot)
|
||||
return layoutGraph({ id: treeId, nodes, edges })
|
||||
}
|
||||
|
||||
function inferMainTree(root: Element, trees: BehaviorTreeGraph[]) {
|
||||
const explicit = root.getAttribute('main_tree_to_execute')
|
||||
if (explicit && trees.some((tree) => tree.id === explicit)) return explicit
|
||||
|
||||
const referenced = new Set<string>()
|
||||
trees.forEach((tree) => tree.nodes.forEach((node) => {
|
||||
if (node.data.nodeType === 'SubTree') referenced.add(node.data.registrationName)
|
||||
}))
|
||||
const rootCandidates = trees.filter((tree) => !referenced.has(tree.id))
|
||||
if (rootCandidates.length === 1) return rootCandidates[0].id
|
||||
return trees.find((tree) => tree.id === 'MainTree')?.id || trees.at(-1)?.id || 'MainTree'
|
||||
}
|
||||
|
||||
export function xmlToDocument(xml: string): BehaviorTreeDocument {
|
||||
const parsed = new DOMParser().parseFromString(xml, 'application/xml')
|
||||
const parserError = parsed.querySelector('parsererror')
|
||||
if (parserError) throw new Error(`XML 解析失败:${parserError.textContent?.split('\n')[0] || '格式错误'}`)
|
||||
const behaviorTree = parsed.querySelector('BehaviorTree')
|
||||
if (!behaviorTree) throw new Error('未找到 <BehaviorTree> 元素')
|
||||
const treeRoot = Array.from(behaviorTree.children).find((child) => child.nodeType === Node.ELEMENT_NODE)
|
||||
if (!treeRoot) throw new Error('BehaviorTree 中没有根节点')
|
||||
|
||||
const nodes: BehaviorNode[] = []
|
||||
const edges: BehaviorEdge[] = []
|
||||
let edgeIndex = 0
|
||||
|
||||
const visit = (element: Element, parentId?: string) => {
|
||||
const type = elementType(element)
|
||||
const node = createNode(type)
|
||||
const idAttr = element.getAttribute('ID')
|
||||
const registrationName = idAttr || (CONTROL_AND_DECORATOR_TYPES.has(type) ? type : element.tagName)
|
||||
const label = element.getAttribute('name') || registrationName
|
||||
const ports: Record<string, string> = {}
|
||||
Array.from(element.attributes).forEach((attribute) => {
|
||||
if (!['ID', 'name', '_autoremap'].includes(attribute.name)) ports[attribute.name] = attribute.value
|
||||
})
|
||||
node.data = { ...node.data, label, registrationName, ports }
|
||||
nodes.push(node)
|
||||
if (parentId) {
|
||||
edges.push({ id: `edge_import_${edgeIndex++}`, source: parentId, target: node.id, type: 'smoothstep' })
|
||||
}
|
||||
Array.from(element.children).forEach((child) => visit(child, node.id))
|
||||
}
|
||||
visit(treeRoot)
|
||||
|
||||
const root = parsed.documentElement
|
||||
const mainTreeId = root.getAttribute('main_tree_to_execute') || behaviorTree.getAttribute('ID') || 'MainTree'
|
||||
return layoutDocument({
|
||||
const treeElements = Array.from(root.children).filter((child) => child.tagName === 'BehaviorTree')
|
||||
if (!treeElements.length) throw new Error('未找到 <BehaviorTree> 元素')
|
||||
|
||||
const nodeModels = parseNodeModels(root)
|
||||
const modelMap = new Map(nodeModels.map((model) => [model.id, model]))
|
||||
const trees = treeElements.map((element) => parseBehaviorTree(element, modelMap))
|
||||
const mainTreeId = inferMainTree(root, trees)
|
||||
const activeTree = trees.find((tree) => tree.id === mainTreeId) || trees[0]
|
||||
|
||||
return {
|
||||
format: 'arborflow/project',
|
||||
version: 1,
|
||||
title: behaviorTree.getAttribute('ID') || 'Imported Tree',
|
||||
title: mainTreeId,
|
||||
mainTreeId,
|
||||
nodes,
|
||||
edges,
|
||||
})
|
||||
activeTreeId: activeTree.id,
|
||||
trees,
|
||||
nodeModels,
|
||||
nodes: activeTree.nodes,
|
||||
edges: activeTree.edges,
|
||||
}
|
||||
}
|
||||
|
||||
103
src/styles.css
103
src/styles.css
@@ -38,7 +38,7 @@ button { color: inherit; }
|
||||
|
||||
.app-header {
|
||||
display: grid;
|
||||
grid-template-columns: 230px minmax(140px, 1fr) auto;
|
||||
grid-template-columns: 248px minmax(140px, 1fr) auto;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #15181b;
|
||||
@@ -65,9 +65,9 @@ button { color: inherit; }
|
||||
background: #17251f;
|
||||
}
|
||||
.brand-copy { align-items: flex-start; flex-direction: column; line-height: 1.1; }
|
||||
.brand-copy strong { font-size: 14px; font-weight: 680; }
|
||||
.brand-copy span { margin-top: 3px; color: var(--text-dim); font-size: 9px; text-transform: uppercase; }
|
||||
.document-title { min-width: 0; overflow: hidden; text-align: center; color: #c5c9ce; font-size: 12px; }
|
||||
.brand-copy strong { font-size: 15px; font-weight: 680; }
|
||||
.brand-copy span { margin-top: 3px; color: var(--text-dim); font-size: 10px; text-transform: uppercase; }
|
||||
.document-title { min-width: 0; overflow: hidden; text-align: center; color: #c5c9ce; font-size: 13px; }
|
||||
.document-title span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.document-title i { display: inline-block; width: 6px; height: 6px; margin-left: 7px; border-radius: 50%; background: var(--amber); vertical-align: 1px; }
|
||||
.header-actions { justify-content: flex-end; gap: 4px; }
|
||||
@@ -97,7 +97,7 @@ button:disabled { cursor: default; opacity: .38; }
|
||||
min-height: 31px;
|
||||
border-radius: 6px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -105,39 +105,50 @@ button:disabled { cursor: default; opacity: .38; }
|
||||
.monitor-button:hover, .monitor-button.active { border-color: #4b9c79; color: #d2f4e6; background: #1c342a; }
|
||||
|
||||
.workspace-toolbar {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 230px 1fr auto;
|
||||
grid-template-columns: 248px 1fr auto;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #121518;
|
||||
padding-right: 12px;
|
||||
}
|
||||
.tree-tab { align-self: stretch; gap: 7px; padding: 0 13px; border-right: 1px solid var(--border); color: #bac0c6; font-size: 11px; }
|
||||
.tree-selector { position: relative; align-self: stretch; min-width: 0; }
|
||||
.tree-tab { display: flex; align-items: center; width: 100%; height: 100%; gap: 7px; padding: 0 13px; border-right: 1px solid var(--border); color: #bac0c6; background: transparent; font-size: 12px; text-align: left; }
|
||||
.tree-tab:hover, .tree-tab.active { color: var(--text); background: var(--surface-2); }
|
||||
.tree-tab span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tree-tab svg:first-child { color: var(--accent); }
|
||||
.tree-tab svg:last-child { margin-left: auto; color: var(--text-dim); }
|
||||
.tree-menu { position: absolute; z-index: 30; top: calc(100% + 1px); left: 7px; width: 260px; overflow: hidden; padding: 6px; border: 1px solid var(--border-strong); border-radius: 6px; background: #191d21; box-shadow: 0 14px 34px rgba(0, 0, 0, .42); }
|
||||
.tree-menu-heading { padding: 7px 8px; color: var(--text-dim); font-size: 10px; font-weight: 650; text-transform: uppercase; }
|
||||
.tree-menu > button { display: flex; align-items: center; justify-content: space-between; width: 100%; min-width: 0; padding: 9px 8px; border-radius: 4px; color: #c3c8cd; background: transparent; font-size: 12px; text-align: left; }
|
||||
.tree-menu > button:hover, .tree-menu > button.active { color: #eff1f2; background: #262c32; }
|
||||
.tree-menu > button.active { box-shadow: inset 2px 0 var(--accent); }
|
||||
.tree-menu > button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tree-menu > button small { flex: 0 0 auto; margin-left: 8px; color: #79828b; font-size: 9px; }
|
||||
.view-switch { justify-self: center; height: 29px; padding: 2px; border: 1px solid var(--border); border-radius: 6px; background: #0f1114; }
|
||||
.view-switch button { display: inline-flex; align-items: center; gap: 5px; height: 23px; padding: 0 10px; border-radius: 4px; color: var(--text-dim); background: transparent; font-size: 11px; }
|
||||
.view-switch button { display: inline-flex; align-items: center; gap: 5px; height: 23px; padding: 0 10px; border-radius: 4px; color: var(--text-dim); background: transparent; font-size: 12px; }
|
||||
.view-switch button.active { color: #e2e5e8; background: var(--surface-3); box-shadow: 0 0 0 1px #303740; }
|
||||
.workspace-toolbar-actions { justify-content: flex-end; }
|
||||
.tool-button { min-height: 28px; border: 1px solid var(--border); color: #aab1b8; background: var(--surface-2); }
|
||||
.tool-button:hover { border-color: var(--border-strong); color: var(--text); background: var(--surface-3); }
|
||||
|
||||
.workspace-grid { display: grid; grid-template-columns: 230px minmax(0, 1fr) 292px; min-height: 0; }
|
||||
.workspace-grid { display: grid; grid-template-columns: 248px minmax(0, 1fr) 320px; min-height: 0; }
|
||||
.left-panel, .right-panel { min-height: 0; background: var(--surface-1); }
|
||||
.left-panel { border-right: 1px solid var(--border); }
|
||||
.right-panel { border-left: 1px solid var(--border); }
|
||||
.panel-heading { display: flex; align-items: center; justify-content: space-between; height: 63px; padding: 0 14px; border-bottom: 1px solid var(--border); }
|
||||
.panel-heading h2 { margin: 3px 0 0; font-size: 13px; font-weight: 650; letter-spacing: 0; }
|
||||
.eyebrow { color: var(--text-dim); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 8px; }
|
||||
.count-label { min-width: 20px; padding: 2px 6px; border: 1px solid var(--border); border-radius: 8px; color: var(--text-dim); background: var(--surface-2); font-size: 9px; text-align: center; }
|
||||
.panel-heading h2 { margin: 3px 0 0; font-size: 15px; font-weight: 650; letter-spacing: 0; }
|
||||
.eyebrow { color: var(--text-dim); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 10px; }
|
||||
.count-label { min-width: 22px; padding: 2px 7px; border: 1px solid var(--border); border-radius: 8px; color: var(--text-dim); background: var(--surface-2); font-size: 10px; text-align: center; }
|
||||
|
||||
.palette-search { display: flex; align-items: center; gap: 7px; height: 48px; margin: 0 10px; border-bottom: 1px solid var(--border); color: var(--text-dim); }
|
||||
.palette-search input { width: 100%; min-width: 0; border: 0; outline: 0; color: var(--text); background: transparent; font-size: 11px; }
|
||||
.palette-search input { width: 100%; min-width: 0; border: 0; outline: 0; color: var(--text); background: transparent; font-size: 12px; }
|
||||
.palette-search input::placeholder, input::placeholder, textarea::placeholder { color: #5f6770; }
|
||||
.palette-scroll, .inspector-scroll { height: calc(100% - 63px); overflow: auto; scrollbar-color: #353c44 transparent; scrollbar-width: thin; }
|
||||
.palette-scroll { height: calc(100% - 111px); padding: 8px 10px 20px; }
|
||||
.palette-group h3 { margin: 11px 4px 6px; color: #727b84; font-size: 9px; font-weight: 650; text-transform: uppercase; }
|
||||
.palette-group h3 { margin: 12px 4px 7px; color: #7c858e; font-size: 10px; font-weight: 650; text-transform: uppercase; }
|
||||
.custom-model-group { margin-bottom: 11px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
|
||||
.palette-items { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
|
||||
.palette-item {
|
||||
display: flex;
|
||||
@@ -153,13 +164,13 @@ button:disabled { cursor: default; opacity: .38; }
|
||||
}
|
||||
.palette-item:hover { border-color: var(--category-color); color: #f0f2f3; background: #20252a; }
|
||||
.palette-item-icon { display: grid; place-items: center; flex: 0 0 23px; color: var(--category-color); }
|
||||
.palette-item-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-size: 9px; white-space: nowrap; }
|
||||
.palette-item-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-size: 11px; white-space: nowrap; }
|
||||
.category-control { --category-color: var(--blue); }
|
||||
.category-decorator { --category-color: var(--purple); }
|
||||
.category-action { --category-color: var(--accent); }
|
||||
.category-condition { --category-color: var(--amber); }
|
||||
.category-subtree { --category-color: #ed756d; }
|
||||
.panel-empty { padding: 35px 10px; color: var(--text-dim); font-size: 11px; text-align: center; }
|
||||
.panel-empty { padding: 35px 10px; color: var(--text-dim); font-size: 12px; text-align: center; }
|
||||
|
||||
.center-stage { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: #101215; }
|
||||
.canvas-region { position: relative; flex: 1 1 auto; min-height: 180px; }
|
||||
@@ -216,9 +227,9 @@ button:disabled { cursor: default; opacity: .38; }
|
||||
.runtime-legend i.runtime-skipped, .event-row > i.runtime-skipped { background-color: #bd82dd; }
|
||||
|
||||
.inspector-scroll { padding: 15px 14px 30px; }
|
||||
.category-chip { max-width: 92px; overflow: hidden; padding: 3px 7px; border: 1px solid color-mix(in srgb, var(--category-color) 45%, #30363d); border-radius: 8px; color: var(--category-color); background: color-mix(in srgb, var(--category-color) 8%, #171a1e); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.category-chip { max-width: 102px; overflow: hidden; padding: 3px 8px; border: 1px solid color-mix(in srgb, var(--category-color) 45%, #30363d); border-radius: 8px; color: var(--category-color); background: color-mix(in srgb, var(--category-color) 8%, #171a1e); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.field-group { min-width: 0; margin-bottom: 13px; }
|
||||
.field-group label, .monitor-connect label { display: block; margin: 0 0 6px; color: #838c95; font-size: 9px; font-weight: 600; }
|
||||
.field-group label, .monitor-connect label { display: block; margin: 0 0 6px; color: #929ba4; font-size: 11px; font-weight: 600; }
|
||||
.field-group input, .field-group textarea, .port-row input, .endpoint-row input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -227,27 +238,29 @@ button:disabled { cursor: default; opacity: .38; }
|
||||
outline: 0;
|
||||
color: #dce0e3;
|
||||
background: #111418;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.field-group input, .port-row input, .endpoint-row input { height: 31px; padding: 0 8px; }
|
||||
.field-group textarea { padding: 8px; resize: vertical; line-height: 1.5; }
|
||||
.field-group input:focus, .field-group textarea:focus, .port-row input:focus, .endpoint-row input:focus { border-color: #547ca7; box-shadow: 0 0 0 2px rgba(84, 124, 167, .14); }
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.read-only-value { height: 31px; overflow: hidden; padding: 8px; border: 1px solid #282e34; border-radius: 5px; color: #7f8891; background: #171a1e; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.read-only-value { height: 31px; overflow: hidden; padding: 7px 8px; border: 1px solid #282e34; border-radius: 5px; color: #8d969f; background: #171a1e; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important; }
|
||||
.section-divider { height: 1px; margin: 16px 0; background: var(--border); }
|
||||
.section-title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||
.section-title { gap: 6px; color: #b9bec4; font-size: 10px; font-weight: 650; }
|
||||
.section-title { gap: 6px; color: #c1c6cb; font-size: 12px; font-weight: 650; }
|
||||
.section-title svg { color: var(--text-dim); }
|
||||
.ports-list { display: flex; flex-direction: column; gap: 5px; }
|
||||
.port-row { display: grid; grid-template-columns: 78px minmax(0, 1fr) 22px; align-items: center; gap: 5px; }
|
||||
.port-row input { height: 29px; font-size: 9px; }
|
||||
.port-key { color: #9fc6e5 !important; font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important; }
|
||||
.inline-empty { padding: 10px; border: 1px dashed #2d333a; border-radius: 5px; color: var(--text-dim); font-size: 9px; text-align: center; }
|
||||
.port-row { display: grid; grid-template-columns: minmax(0, 1fr) 22px; align-items: center; gap: 5px; }
|
||||
.port-row input { height: 30px; font-size: 10px; }
|
||||
.port-key { grid-column: 1; grid-row: 1; color: #9fc6e5 !important; font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important; }
|
||||
.port-row > input:not(.port-key) { grid-column: 1 / -1; grid-row: 2; }
|
||||
.port-row > button { grid-column: 2; grid-row: 1; }
|
||||
.inline-empty { padding: 10px; border: 1px dashed #2d333a; border-radius: 5px; color: var(--text-dim); font-size: 10px; text-align: center; }
|
||||
.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 15px; cursor: pointer; }
|
||||
.toggle-row > span { display: flex; flex-direction: column; min-width: 0; }
|
||||
.toggle-title { color: #c0c5ca; font-size: 10px; font-weight: 600; }
|
||||
.toggle-description { margin-top: 3px; color: var(--text-dim); font-size: 8px; }
|
||||
.toggle-title { color: #c0c5ca; font-size: 11px; font-weight: 600; }
|
||||
.toggle-description { margin-top: 3px; color: var(--text-dim); font-size: 10px; }
|
||||
.toggle-row input { position: relative; width: 28px; height: 16px; margin: 0; appearance: none; border: 1px solid #424a53; border-radius: 8px; background: #24292f; transition: .15s; }
|
||||
.toggle-row input::after { position: absolute; top: 2px; left: 2px; width: 10px; height: 10px; border-radius: 50%; background: #818a94; content: ''; transition: .15s; }
|
||||
.toggle-row input:checked { border-color: #318963; background: #245d49; }
|
||||
@@ -259,20 +272,20 @@ button:disabled { cursor: default; opacity: .38; }
|
||||
.tree-summary div { display: flex; flex-direction: column; align-items: center; padding: 12px 2px; border-right: 1px solid var(--border); }
|
||||
.tree-summary div:last-child { border-right: 0; }
|
||||
.tree-summary strong { font-size: 15px; font-weight: 650; }
|
||||
.tree-summary span { margin-top: 4px; color: var(--text-dim); font-size: 8px; }
|
||||
.tree-summary span { margin-top: 4px; color: var(--text-dim); font-size: 10px; }
|
||||
.issue-list { display: flex; flex-direction: column; gap: 5px; }
|
||||
.issue-row { display: grid; grid-template-columns: 7px 1fr; gap: 7px; width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 5px; color: #a7aeb5; background: #191d21; font-size: 9px; line-height: 1.45; text-align: left; }
|
||||
.issue-row { display: grid; grid-template-columns: 7px 1fr; gap: 7px; width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 5px; color: #a7aeb5; background: #191d21; font-size: 10px; line-height: 1.45; text-align: left; }
|
||||
.issue-row:not(:disabled):hover { border-color: #4a535d; background: #20252a; }
|
||||
.issue-row:disabled { opacity: 1; cursor: default; }
|
||||
.issue-dot { width: 6px; height: 6px; margin-top: 3px; border-radius: 50%; background: var(--amber); }
|
||||
.issue-row.error .issue-dot { background: var(--red); }
|
||||
.validation-ok { padding: 9px; border: 1px solid #285441; border-radius: 5px; color: #74cda9; background: #14231d; font-size: 9px; }
|
||||
.validation-ok { padding: 9px; border: 1px solid #285441; border-radius: 5px; color: #74cda9; background: #14231d; font-size: 10px; }
|
||||
|
||||
.monitor-panel { flex: 0 0 250px; min-height: 0; border-top: 1px solid #384047; background: #15191d; box-shadow: 0 -9px 22px rgba(0, 0, 0, .18); }
|
||||
.monitor-header { display: flex; align-items: center; justify-content: space-between; height: 39px; padding: 0 11px 0 14px; border-bottom: 1px solid var(--border); }
|
||||
.monitor-title { gap: 7px; font-size: 10px; }
|
||||
.monitor-title { gap: 7px; font-size: 12px; }
|
||||
.monitor-title > svg { color: var(--accent); }
|
||||
.connection-pill { display: inline-flex; align-items: center; gap: 5px; margin-left: 4px; padding: 2px 6px; border: 1px solid var(--border); border-radius: 8px; color: var(--text-dim); font-size: 8px; font-weight: 500; }
|
||||
.connection-pill { display: inline-flex; align-items: center; gap: 5px; margin-left: 4px; padding: 2px 6px; border: 1px solid var(--border); border-radius: 8px; color: var(--text-dim); font-size: 10px; font-weight: 500; }
|
||||
.connection-pill i { width: 5px; height: 5px; border-radius: 50%; background: #69727c; }
|
||||
.connection-pill.connected { border-color: #2c674e; color: #78d6af; }
|
||||
.connection-pill.connected i { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
|
||||
@@ -297,43 +310,43 @@ button:disabled { cursor: default; opacity: .38; }
|
||||
.monitor-metrics div { display: flex; flex-direction: column; border-right: 1px solid var(--border); }
|
||||
.monitor-metrics div + div { padding-left: 10px; }
|
||||
.monitor-metrics div:last-child { border-right: 0; }
|
||||
.monitor-metrics span { color: var(--text-dim); font-size: 8px; }
|
||||
.monitor-metrics strong { margin-top: 4px; color: #c6cbd0; font-size: 10px; }
|
||||
.monitor-metrics span { color: var(--text-dim); font-size: 10px; }
|
||||
.monitor-metrics strong { margin-top: 4px; color: #c6cbd0; font-size: 11px; }
|
||||
.runtime-legend { flex-direction: column; align-items: flex-start; justify-content: center; gap: 11px; padding: 10px 13px; border-right: 1px solid var(--border); }
|
||||
.runtime-legend span { display: flex; align-items: center; gap: 7px; color: #818a93; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 8px; }
|
||||
.runtime-legend span { display: flex; align-items: center; gap: 7px; color: #929aa3; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 9px; }
|
||||
.runtime-legend i, .event-row > i { width: 7px; height: 7px; border-radius: 50%; }
|
||||
.event-stream { flex-direction: column; align-items: stretch; min-width: 0; min-height: 0; }
|
||||
.event-stream-heading { justify-content: space-between; flex: 0 0 30px; padding: 0 12px; border-bottom: 1px solid var(--border); color: #777f88; font-size: 8px; font-weight: 650; text-transform: uppercase; }
|
||||
.event-stream-heading { justify-content: space-between; flex: 0 0 30px; padding: 0 12px; border-bottom: 1px solid var(--border); color: #858e97; font-size: 10px; font-weight: 650; text-transform: uppercase; }
|
||||
.event-list { overflow: auto; min-height: 0; padding: 4px 8px; scrollbar-width: thin; scrollbar-color: #353c44 transparent; }
|
||||
.event-row { display: grid; grid-template-columns: 54px 7px minmax(70px, 1fr) 58px 42px; align-items: center; gap: 7px; min-height: 25px; border-bottom: 1px solid #24292e; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 8px; }
|
||||
.event-row { display: grid; grid-template-columns: 62px 7px minmax(70px, 1fr) 62px 45px; align-items: center; gap: 7px; min-height: 27px; border-bottom: 1px solid #24292e; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 9px; }
|
||||
.event-row time { color: #626b74; }
|
||||
.event-node { overflow: hidden; color: #bec4ca; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.event-status { background: transparent !important; font-size: 7px; }
|
||||
.event-status { background: transparent !important; font-size: 8px; }
|
||||
.unmatched { color: #bb716c; }
|
||||
.event-empty { padding: 55px 10px; color: #5f6770; font-size: 9px; text-align: center; }
|
||||
.event-empty { padding: 55px 10px; color: #68717b; font-size: 10px; text-align: center; }
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.xml-view { position: absolute; inset: 0; display: flex; flex-direction: column; padding: 18px; background: #101215; }
|
||||
.xml-header { display: flex; align-items: center; justify-content: space-between; flex: 0 0 38px; padding: 0 9px 0 12px; border: 1px solid var(--border); border-bottom: 0; border-radius: 6px 6px 0 0; color: #aab1b8; background: #1b1f23; font-size: 10px; }
|
||||
.xml-header { display: flex; align-items: center; justify-content: space-between; flex: 0 0 38px; padding: 0 9px 0 12px; border: 1px solid var(--border); border-bottom: 0; border-radius: 6px 6px 0 0; color: #aab1b8; background: #1b1f23; font-size: 11px; }
|
||||
.xml-header > div { gap: 7px; }
|
||||
.xml-header svg { color: var(--accent); }
|
||||
.xml-view pre { flex: 1; min-height: 0; margin: 0; overflow: auto; padding: 18px; border: 1px solid var(--border); border-radius: 0 0 6px 6px; color: #bbd5c9; background: #0d1012; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; line-height: 1.65; tab-size: 4; }
|
||||
.xml-view pre { flex: 1; min-height: 0; margin: 0; overflow: auto; padding: 18px; border: 1px solid var(--border); border-radius: 0 0 6px 6px; color: #bbd5c9; background: #0d1012; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; line-height: 1.65; tab-size: 4; }
|
||||
.xml-error { display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center; border: 1px solid var(--border); color: var(--red); background: #141213; }
|
||||
.xml-error strong { margin-top: 10px; font-size: 12px; }
|
||||
.xml-error span { max-width: 460px; margin-top: 7px; color: #9b7774; font-size: 10px; text-align: center; }
|
||||
|
||||
.status-bar { display: flex; align-items: center; justify-content: space-between; border-top: 1px solid #242a30; padding: 0 10px; color: #626b75; background: #111417; font-size: 8px; user-select: none; }
|
||||
.status-bar { display: flex; align-items: center; justify-content: space-between; border-top: 1px solid #242a30; padding: 0 10px; color: #76808a; background: #111417; font-size: 10px; user-select: none; }
|
||||
.status-bar > div { display: flex; align-items: center; gap: 13px; }
|
||||
.status-valid, .status-error { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.status-valid { color: #5fbf98; }
|
||||
.status-error { color: #df746e; }
|
||||
.toast { position: fixed; z-index: 100; left: 50%; bottom: 38px; max-width: min(440px, 70vw); transform: translateX(-50%); padding: 9px 13px; border: 1px solid #356e57; border-radius: 6px; color: #c7eddd; background: #193127; box-shadow: 0 9px 25px rgba(0, 0, 0, .35); font-size: 10px; }
|
||||
.toast { position: fixed; z-index: 100; left: 50%; bottom: 38px; max-width: min(440px, 70vw); transform: translateX(-50%); padding: 9px 13px; border: 1px solid #356e57; border-radius: 6px; color: #c7eddd; background: #193127; box-shadow: 0 9px 25px rgba(0, 0, 0, .35); font-size: 11px; }
|
||||
.toast.error { border-color: #763b37; color: #f4b5b0; background: #351d1b; }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.workspace-grid { grid-template-columns: 205px minmax(0, 1fr) 265px; }
|
||||
.workspace-toolbar, .app-header { grid-template-columns: 205px minmax(120px, 1fr) auto; }
|
||||
.workspace-grid { grid-template-columns: 220px minmax(0, 1fr) 286px; }
|
||||
.workspace-toolbar, .app-header { grid-template-columns: 220px minmax(120px, 1fr) auto; }
|
||||
.brand-copy span { display: none; }
|
||||
.palette-items { grid-template-columns: 1fr; }
|
||||
.monitor-body { grid-template-columns: minmax(260px, 42%) 92px minmax(220px, 1fr); }
|
||||
|
||||
25
src/types.ts
25
src/types.ts
@@ -29,6 +29,7 @@ export type BehaviorNodeData = {
|
||||
ports: Record<string, string>
|
||||
notes: string
|
||||
breakpoint: boolean
|
||||
xmlTag?: string
|
||||
runtimeStatus?: RuntimeStatus
|
||||
[key: string]: unknown
|
||||
}
|
||||
@@ -36,11 +37,35 @@ export type BehaviorNodeData = {
|
||||
export type BehaviorNode = Node<BehaviorNodeData, 'behavior'>
|
||||
export type BehaviorEdge = Edge
|
||||
|
||||
export interface BehaviorTreeGraph {
|
||||
id: string
|
||||
nodes: BehaviorNode[]
|
||||
edges: BehaviorEdge[]
|
||||
}
|
||||
|
||||
export interface NodePortModel {
|
||||
name: string
|
||||
direction: 'input_port' | 'output_port' | 'inout_port'
|
||||
type: string
|
||||
defaultValue?: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface CustomNodeModel {
|
||||
id: string
|
||||
nodeType: NodeType
|
||||
category: NodeCategory
|
||||
ports: NodePortModel[]
|
||||
}
|
||||
|
||||
export interface BehaviorTreeDocument {
|
||||
format: 'arborflow/project'
|
||||
version: 1
|
||||
title: string
|
||||
mainTreeId: string
|
||||
activeTreeId?: string
|
||||
trees?: BehaviorTreeGraph[]
|
||||
nodeModels?: CustomNodeModel[]
|
||||
nodes: BehaviorNode[]
|
||||
edges: BehaviorEdge[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user