fix: support multi-tree BehaviorTree XML imports

This commit is contained in:
TuxMonkey
2026-07-21 22:16:29 +08:00
parent 5cc4c73e47
commit 82374aabd2
10 changed files with 431 additions and 127 deletions

View File

@@ -2,7 +2,7 @@
ArborFlow 是一个面向 [BehaviorTree.CPP](https://github.com/BehaviorTree/BehaviorTree.CPP) 的跨平台行为树生成、编辑与运行状态可视化工具。 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` 工程文件 - 保存和打开 `.arborflow` 工程文件
- 导入、导出和预览 BehaviorTree.CPP v4 XML - 导入、导出和预览 BehaviorTree.CPP v4 XML
- 导入多棵 `BehaviorTree` 并在顶部树选择器中切换
- 解析 `TreeNodesModel`,加载自定义节点及其端口默认值
- 通过 WebSocket 或 `rosbridge_server` 实时显示节点状态 - 通过 WebSocket 或 `rosbridge_server` 实时显示节点状态
- 实时事件流、消息计数、节点匹配统计、延迟检测和自动重连 - 实时事件流、消息计数、节点匹配统计、延迟检测和自动重连
@@ -65,10 +67,11 @@ npm run preview
2. 从父节点底部连接点拖到子节点顶部连接点。 2. 从父节点底部连接点拖到子节点顶部连接点。
3. 选中节点后,在右侧检查器修改名称、端口、黑板参数或断点。 3. 选中节点后,在右侧检查器修改名称、端口、黑板参数或断点。
4. 使用工具栏的自动布局按钮整理整棵树。 4. 使用工具栏的自动布局按钮整理整棵树。
5. 将工程保存为 `.arborflow`,或导出为 BehaviorTree.CPP XML。 5. 多树工程可通过画布左上方的树选择器切换当前行为树。
6. 打开 Monitor,填写机器人地址后连接实时状态流。 6. 将工程保存为 `.arborflow`,或导出为 BehaviorTree.CPP XML。
7. 打开 Monitor,填写机器人地址后连接实时状态流。
工程文件保存编辑器坐标、节点 ID、端口、备注等完整信息。XML 只保存 BehaviorTree.CPP 可执行数据,不包含编辑器坐标或实时运行状态。 工程文件保存全部行为树、编辑器坐标、节点 ID、节点模型、端口和备注等完整信息。XML 不包含编辑器坐标或实时运行状态。
## BehaviorTree.CPP 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 ## Real-time Monitor
监视器支持两种接入方式: 监视器支持两种接入方式:

View File

@@ -37,10 +37,11 @@ import { BehaviorNodeCard } from './components/BehaviorNodeCard'
import { Inspector } from './components/Inspector' import { Inspector } from './components/Inspector'
import { MonitorPanel } from './components/MonitorPanel' import { MonitorPanel } from './components/MonitorPanel'
import { NodePalette } from './components/NodePalette' 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 { createDemoDocument, createEmptyDocument } from './lib/demo'
import { layoutDocument } from './lib/layout' import { layoutDocument } from './lib/layout'
import { downloadText, parseProject, pickBrowserFile, serializeProject } from './lib/project' import { downloadText, parseProject, pickBrowserFile, serializeProject } from './lib/project'
import { activeTreeId, switchDocumentTree, syncedTreeGraphs } from './lib/trees'
import { validateDocument, wouldCreateCycle } from './lib/validation' import { validateDocument, wouldCreateCycle } from './lib/validation'
import { documentToXml, xmlToDocument } from './lib/xml' import { documentToXml, xmlToDocument } from './lib/xml'
import type { BehaviorNode, BehaviorTreeDocument, NodeType, RuntimeStatus } from './types' import type { BehaviorNode, BehaviorTreeDocument, NodeType, RuntimeStatus } from './types'
@@ -57,6 +58,7 @@ function Editor() {
const [projectPath, setProjectPath] = useState<string | null>(null) const [projectPath, setProjectPath] = useState<string | null>(null)
const [dirty, setDirty] = useState(false) const [dirty, setDirty] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('canvas') const [viewMode, setViewMode] = useState<ViewMode>('canvas')
const [treeMenuOpen, setTreeMenuOpen] = useState(false)
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null) const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null) const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
const [monitorOpen, setMonitorOpen] = useState(false) const [monitorOpen, setMonitorOpen] = useState(false)
@@ -74,6 +76,8 @@ function Editor() {
() => treeDocument.nodes.find((node) => node.id === selectedNodeId) || null, () => treeDocument.nodes.find((node) => node.id === selectedNodeId) || null,
[treeDocument.nodes, selectedNodeId], [treeDocument.nodes, selectedNodeId],
) )
const treeGraphs = useMemo(() => syncedTreeGraphs(treeDocument), [treeDocument])
const currentTreeId = activeTreeId(treeDocument)
const issues = useMemo(() => validateDocument(treeDocument), [treeDocument]) const issues = useMemo(() => validateDocument(treeDocument), [treeDocument])
const xmlPreview = useMemo(() => { const xmlPreview = useMemo(() => {
@@ -149,6 +153,32 @@ function Editor() {
setViewMode('canvas') setViewMode('canvas')
}, [commit, screenToFlowPosition]) }, [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) => { const deleteNode = useCallback((nodeId: string) => {
commit((current) => ({ commit((current) => ({
...current, ...current,
@@ -292,10 +322,15 @@ function Editor() {
const handleDrop = useCallback((event: React.DragEvent) => { const handleDrop = useCallback((event: React.DragEvent) => {
event.preventDefault() 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 const type = event.dataTransfer.getData('application/arborflow-node') as NodeType
if (!type) return if (!type) return
addNodeAt(type, screenToFlowPosition({ x: event.clientX, y: event.clientY })) addNodeAt(type, screenToFlowPosition({ x: event.clientX, y: event.clientY }))
}, [addNodeAt, screenToFlowPosition]) }, [addCustomNodeAt, addNodeAt, screenToFlowPosition])
const copyXml = useCallback(async () => { const copyXml = useCallback(async () => {
if (!xmlPreview.value) return if (!xmlPreview.value) return
@@ -415,7 +450,22 @@ function Editor() {
</header> </header>
<div className="workspace-toolbar"> <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="视图切换"> <div className="view-switch" aria-label="视图切换">
<button className={viewMode === 'canvas' ? 'active' : ''} onClick={() => setViewMode('canvas')}><TreePine size={14} />画布</button> <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> <button className={viewMode === 'xml' ? 'active' : ''} onClick={() => setViewMode('xml')}><Code2 size={14} />XML</button>
@@ -426,7 +476,7 @@ function Editor() {
</div> </div>
<main className="workspace-grid"> <main className="workspace-grid">
<NodePalette onAddNode={addNodeAt} /> <NodePalette customModels={treeDocument.nodeModels || []} onAddNode={addNodeAt} onAddCustomNode={addCustomNodeAt} />
<section className="center-stage"> <section className="center-stage">
<div className="canvas-region" onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'copy' }} onDrop={handleDrop}> <div className="canvas-region" onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'copy' }} onDrop={handleDrop}>
{viewMode === 'canvas' ? ( {viewMode === 'canvas' ? (
@@ -438,7 +488,7 @@ function Editor() {
onConnect={connectNodes} onConnect={connectNodes}
onNodeClick={(_event, node) => { setSelectedNodeId(node.id); setSelectedEdgeId(null) }} onNodeClick={(_event, node) => { setSelectedNodeId(node.id); setSelectedEdgeId(null) }}
onEdgeClick={(_event, edge) => { setSelectedEdgeId(edge.id); setSelectedNodeId(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 }} onNodeDragStart={() => { dragStartRef.current = treeDocument }}
onNodeDragStop={() => { onNodeDragStop={() => {
if (dragStartRef.current) { if (dragStartRef.current) {
@@ -508,7 +558,7 @@ function Editor() {
<footer className="status-bar"> <footer className="status-bar">
<div><span className={errors ? 'status-error' : 'status-valid'}>{errors ? `${errors} 个结构错误` : <><Check size={12} />结构有效</>}</span></div> <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> </footer>
{toast && <div className={`toast ${toast.kind}`}>{toast.message}</div>} {toast && <div className={`toast ${toast.kind}`}>{toast.message}</div>}
</div> </div>

View File

@@ -1,30 +1,40 @@
import { Search, X } from 'lucide-react' import { Search, SquareFunction, X, Zap } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { CATEGORY_LABELS, NODE_DEFINITIONS } from '../lib/catalog' import { CATEGORY_LABELS, NODE_DEFINITIONS } from '../lib/catalog'
import type { NodeCategory, NodeType } from '../types' import type { CustomNodeModel, NodeCategory, NodeType } from '../types'
interface NodePaletteProps { interface NodePaletteProps {
customModels: CustomNodeModel[]
onAddNode: (type: NodeType) => void onAddNode: (type: NodeType) => void
onAddCustomNode: (modelId: string) => void
} }
const CATEGORY_ORDER: NodeCategory[] = ['control', 'decorator', 'action', 'condition', 'subtree'] 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 [query, setQuery] = useState('')
const groups = useMemo(() => { const normalizedQuery = query.trim().toLowerCase()
const normalized = query.trim().toLowerCase() const groups = useMemo(() => CATEGORY_ORDER.map((category) => ({
return CATEGORY_ORDER.map((category) => ({
category, category,
items: NODE_DEFINITIONS.filter((item) => item.category === category) items: NODE_DEFINITIONS.filter((item) => item.category === category)
.filter((item) => !normalized || `${item.label} ${item.description} ${item.type}`.toLowerCase().includes(normalized)), .filter((item) => !normalizedQuery || `${item.label} ${item.description} ${item.type}`.toLowerCase().includes(normalizedQuery)),
})).filter((group) => group.items.length) })).filter((group) => group.items.length), [normalizedQuery])
}, [query])
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) => { const beginDrag = (event: React.DragEvent, type: NodeType) => {
event.dataTransfer.setData('application/arborflow-node', type) event.dataTransfer.setData('application/arborflow-node', type)
event.dataTransfer.effectAllowed = 'copy' event.dataTransfer.effectAllowed = 'copy'
} }
const beginCustomDrag = (event: React.DragEvent, modelId: string) => {
event.dataTransfer.setData('application/arborflow-model', modelId)
event.dataTransfer.effectAllowed = 'copy'
}
return ( return (
<aside className="left-panel"> <aside className="left-panel">
<div className="panel-heading"> <div className="panel-heading">
@@ -32,14 +42,37 @@ export function NodePalette({ onAddNode }: NodePaletteProps) {
<span className="eyebrow">LIBRARY</span> <span className="eyebrow">LIBRARY</span>
<h2>节点</h2> <h2>节点</h2>
</div> </div>
<span className="count-label">{NODE_DEFINITIONS.length}</span> <span className="count-label">{NODE_DEFINITIONS.length + customModels.length}</span>
</div> </div>
<div className="palette-search"> <div className="palette-search">
<Search size={15} /> <Search size={16} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索节点" aria-label="搜索节点" /> <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>
<div className="palette-scroll"> <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 }) => ( {groups.map(({ category, items }) => (
<section className="palette-group" key={category}> <section className="palette-group" key={category}>
<h3>{CATEGORY_LABELS[category]}</h3> <h3>{CATEGORY_LABELS[category]}</h3>
@@ -63,7 +96,7 @@ export function NodePalette({ onAddNode }: NodePaletteProps) {
</div> </div>
</section> </section>
))} ))}
{!groups.length && <div className="panel-empty">没有匹配节点</div>} {!groups.length && !visibleModels.length && <div className="panel-empty">没有匹配节点</div>}
</div> </div>
</aside> </aside>
) )

View File

@@ -11,7 +11,7 @@ import {
Zap, Zap,
type LucideIcon, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import type { BehaviorNode, NodeCategory, NodeType } from '../types' import type { BehaviorNode, CustomNodeModel, NodeCategory, NodeType } from '../types'
export interface NodeDefinition { export interface NodeDefinition {
type: NodeType 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) { export function canHaveChildren(type: NodeType) {
return !['Action', 'Condition', 'SubTree', 'AlwaysSuccess', 'AlwaysFailure'].includes(type) return !['Action', 'Condition', 'SubTree', 'AlwaysSuccess', 'AlwaysFailure'].includes(type)
} }

View File

@@ -4,7 +4,7 @@ import type { BehaviorTreeDocument } from '../types'
const NODE_WIDTH = 210 const NODE_WIDTH = 210
const NODE_HEIGHT = 84 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() const graph = new dagre.graphlib.Graph()
graph.setDefaultEdgeLabel(() => ({})) graph.setDefaultEdgeLabel(() => ({}))
graph.setGraph({ rankdir: 'TB', nodesep: 46, ranksep: 88, marginx: 40, marginy: 40 }) 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)
}

View File

@@ -1,7 +1,8 @@
import { syncActiveTree, switchDocumentTree } from './trees'
import type { BehaviorTreeDocument } from '../types' import type { BehaviorTreeDocument } from '../types'
export function serializeProject(document: BehaviorTreeDocument) { 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 { 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)) { if (!Array.isArray(candidate.nodes) || !Array.isArray(candidate.edges)) {
throw new Error('工程文件缺少节点或连接数据') 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') { export function downloadText(filename: string, content: string, type = 'text/plain') {

39
src/lib/trees.ts Normal file
View 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,
}
}

View File

@@ -1,12 +1,24 @@
import { createNode, NODE_DEFINITION_MAP } from './catalog' import { createNode, NODE_DEFINITION_MAP } from './catalog'
import { layoutDocument } from './layout' import { layoutGraph } from './layout'
import type { BehaviorEdge, BehaviorNode, BehaviorTreeDocument, NodeType } from '../types' import { syncedTreeGraphs } from './trees'
import type {
BehaviorEdge,
BehaviorNode,
BehaviorTreeDocument,
BehaviorTreeGraph,
CustomNodeModel,
NodeCategory,
NodePortModel,
NodeType,
} from '../types'
const CONTROL_AND_DECORATOR_TYPES = new Set<NodeType>([ const CONTROL_AND_DECORATOR_TYPES = new Set<NodeType>([
'Sequence', 'Fallback', 'Parallel', 'ReactiveSequence', 'ReactiveFallback', 'Sequence', 'Fallback', 'Parallel', 'ReactiveSequence', 'ReactiveFallback',
'Inverter', 'RetryUntilSuccessful', 'Repeat', 'ForceSuccess', 'ForceFailure', 'Inverter', 'RetryUntilSuccessful', 'Repeat', 'ForceSuccess', 'ForceFailure',
]) ])
const PORT_TAGS = new Set<NodePortModel['direction']>(['input_port', 'output_port', 'inout_port'])
function escapeXml(value: string) { function escapeXml(value: string) {
return value return value
.replaceAll('&', '&amp;') .replaceAll('&', '&amp;')
@@ -17,22 +29,23 @@ function escapeXml(value: string) {
} }
function nodeTag(node: BehaviorNode) { function nodeTag(node: BehaviorNode) {
if (node.data.xmlTag) return node.data.xmlTag
if (node.data.nodeType === 'SubTree') return 'SubTree' if (node.data.nodeType === 'SubTree') return 'SubTree'
if (node.data.nodeType === 'Action') return 'Action' if (node.data.nodeType === 'Action') return 'Action'
if (node.data.nodeType === 'Condition') return 'Condition' if (node.data.nodeType === 'Condition') return 'Condition'
return node.data.nodeType return node.data.nodeType
} }
export function documentToXml(document: BehaviorTreeDocument) { function renderTree(graph: BehaviorTreeGraph) {
if (document.nodes.length === 0) throw new Error('画布为空,无法导出 XML') if (graph.nodes.length === 0) throw new Error(`${graph.id} 画布为空,无法导出 XML`)
const incoming = new Map(document.nodes.map((node) => [node.id, 0])) const incoming = new Map(graph.nodes.map((node) => [node.id, 0]))
document.edges.forEach((edge) => incoming.set(edge.target, (incoming.get(edge.target) || 0) + 1)) graph.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) const roots = graph.nodes.filter((node) => (incoming.get(node.id) || 0) === 0)
if (roots.length !== 1) throw new Error(`XML 只能导出一棵完整树,当前检测到 ${roots.length} 个根节点`) 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[]>() const children = new Map<string, BehaviorNode[]>()
document.edges.forEach((edge) => { graph.edges.forEach((edge) => {
const child = byId.get(edge.target) const child = byId.get(edge.target)
if (child) children.set(edge.source, [...(children.get(edge.source) || []), child]) 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 visited = new Set<string>()
const renderNode = (node: BehaviorNode, depth: number): 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) visited.add(node.id)
const indent = ' '.repeat(depth) const indent = ' '.repeat(depth)
const tag = nodeTag(node) const tag = nodeTag(node)
const attributes: Array<[string, string]> = [] 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]) 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]) => { Object.entries(node.data.ports).forEach(([key, value]) => {
if (key.trim()) attributes.push([key.trim(), 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}>` return `${indent}<${tag}${attrs}>\n${content}\n${indent}</${tag}>`
} }
const treeXml = renderNode(roots[0], 2) return ` <BehaviorTree ID="${escapeXml(graph.id)}">\n${renderNode(roots[0], 2)}\n </BehaviorTree>`
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`
} }
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 const tag = element.tagName
if (NODE_DEFINITION_MAP.has(tag as NodeType)) return tag as NodeType if (NODE_DEFINITION_MAP.has(tag as NodeType)) {
if (tag === 'SubTreePlus') return 'SubTree' const definition = NODE_DEFINITION_MAP.get(tag as NodeType)!
const children = Array.from(element.children) return { nodeType: tag as NodeType, category: definition.category }
return children.length ? 'Sequence' : 'Action' }
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 { export function xmlToDocument(xml: string): BehaviorTreeDocument {
const parsed = new DOMParser().parseFromString(xml, 'application/xml') const parsed = new DOMParser().parseFromString(xml, 'application/xml')
const parserError = parsed.querySelector('parsererror') const parserError = parsed.querySelector('parsererror')
if (parserError) throw new Error(`XML 解析失败:${parserError.textContent?.split('\n')[0] || '格式错误'}`) 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 root = parsed.documentElement
const mainTreeId = root.getAttribute('main_tree_to_execute') || behaviorTree.getAttribute('ID') || 'MainTree' const treeElements = Array.from(root.children).filter((child) => child.tagName === 'BehaviorTree')
return layoutDocument({ 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', format: 'arborflow/project',
version: 1, version: 1,
title: behaviorTree.getAttribute('ID') || 'Imported Tree', title: mainTreeId,
mainTreeId, mainTreeId,
nodes, activeTreeId: activeTree.id,
edges, trees,
}) nodeModels,
nodes: activeTree.nodes,
edges: activeTree.edges,
}
} }

View File

@@ -38,7 +38,7 @@ button { color: inherit; }
.app-header { .app-header {
display: grid; display: grid;
grid-template-columns: 230px minmax(140px, 1fr) auto; grid-template-columns: 248px minmax(140px, 1fr) auto;
align-items: center; align-items: center;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: #15181b; background: #15181b;
@@ -65,9 +65,9 @@ button { color: inherit; }
background: #17251f; background: #17251f;
} }
.brand-copy { align-items: flex-start; flex-direction: column; line-height: 1.1; } .brand-copy { align-items: flex-start; flex-direction: column; line-height: 1.1; }
.brand-copy strong { font-size: 14px; font-weight: 680; } .brand-copy strong { font-size: 15px; font-weight: 680; }
.brand-copy span { margin-top: 3px; color: var(--text-dim); font-size: 9px; text-transform: uppercase; } .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: 12px; } .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 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; } .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; } .header-actions { justify-content: flex-end; gap: 4px; }
@@ -97,7 +97,7 @@ button:disabled { cursor: default; opacity: .38; }
min-height: 31px; min-height: 31px;
border-radius: 6px; border-radius: 6px;
padding: 0 10px; padding: 0 10px;
font-size: 12px; font-size: 13px;
font-weight: 600; font-weight: 600;
white-space: nowrap; 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; } .monitor-button:hover, .monitor-button.active { border-color: #4b9c79; color: #d2f4e6; background: #1c342a; }
.workspace-toolbar { .workspace-toolbar {
position: relative;
display: grid; display: grid;
grid-template-columns: 230px 1fr auto; grid-template-columns: 248px 1fr auto;
align-items: center; align-items: center;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: #121518; background: #121518;
padding-right: 12px; 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 span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-tab svg:first-child { color: var(--accent); } .tree-tab svg:first-child { color: var(--accent); }
.tree-tab svg:last-child { margin-left: auto; color: var(--text-dim); } .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 { 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; } .view-switch button.active { color: #e2e5e8; background: var(--surface-3); box-shadow: 0 0 0 1px #303740; }
.workspace-toolbar-actions { justify-content: flex-end; } .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 { 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); } .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, .right-panel { min-height: 0; background: var(--surface-1); }
.left-panel { border-right: 1px solid var(--border); } .left-panel { border-right: 1px solid var(--border); }
.right-panel { border-left: 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 { 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; } .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: 8px; } .eyebrow { color: var(--text-dim); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 10px; }
.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; } .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 { 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-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, .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-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-items { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
.palette-item { .palette-item {
display: flex; 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: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-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-control { --category-color: var(--blue); }
.category-decorator { --category-color: var(--purple); } .category-decorator { --category-color: var(--purple); }
.category-action { --category-color: var(--accent); } .category-action { --category-color: var(--accent); }
.category-condition { --category-color: var(--amber); } .category-condition { --category-color: var(--amber); }
.category-subtree { --category-color: #ed756d; } .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; } .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; } .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; } .runtime-legend i.runtime-skipped, .event-row > i.runtime-skipped { background-color: #bd82dd; }
.inspector-scroll { padding: 15px 14px 30px; } .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 { 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 { .field-group input, .field-group textarea, .port-row input, .endpoint-row input {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -227,27 +238,29 @@ button:disabled { cursor: default; opacity: .38; }
outline: 0; outline: 0;
color: #dce0e3; color: #dce0e3;
background: #111418; background: #111418;
font-size: 11px; font-size: 12px;
} }
.field-group input, .port-row input, .endpoint-row input { height: 31px; padding: 0 8px; } .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 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-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; } .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; } .mono { font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important; }
.section-divider { height: 1px; margin: 16px 0; background: var(--border); } .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-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); } .section-title svg { color: var(--text-dim); }
.ports-list { display: flex; flex-direction: column; gap: 5px; } .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 { display: grid; grid-template-columns: minmax(0, 1fr) 22px; align-items: center; gap: 5px; }
.port-row input { height: 29px; font-size: 9px; } .port-row input { height: 30px; font-size: 10px; }
.port-key { color: #9fc6e5 !important; font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important; } .port-key { grid-column: 1; grid-row: 1; 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 > 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 { 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-row > span { display: flex; flex-direction: column; min-width: 0; }
.toggle-title { color: #c0c5ca; font-size: 10px; font-weight: 600; } .toggle-title { color: #c0c5ca; font-size: 11px; font-weight: 600; }
.toggle-description { margin-top: 3px; color: var(--text-dim); font-size: 8px; } .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 { 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::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; } .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 { 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 div:last-child { border-right: 0; }
.tree-summary strong { font-size: 15px; font-weight: 650; } .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-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:not(:disabled):hover { border-color: #4a535d; background: #20252a; }
.issue-row:disabled { opacity: 1; cursor: default; } .issue-row:disabled { opacity: 1; cursor: default; }
.issue-dot { width: 6px; height: 6px; margin-top: 3px; border-radius: 50%; background: var(--amber); } .issue-dot { width: 6px; height: 6px; margin-top: 3px; border-radius: 50%; background: var(--amber); }
.issue-row.error .issue-dot { background: var(--red); } .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-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-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); } .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 i { width: 5px; height: 5px; border-radius: 50%; background: #69727c; }
.connection-pill.connected { border-color: #2c674e; color: #78d6af; } .connection-pill.connected { border-color: #2c674e; color: #78d6af; }
.connection-pill.connected i { background: var(--accent); box-shadow: 0 0 6px var(--accent); } .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 { display: flex; flex-direction: column; border-right: 1px solid var(--border); }
.monitor-metrics div + div { padding-left: 10px; } .monitor-metrics div + div { padding-left: 10px; }
.monitor-metrics div:last-child { border-right: 0; } .monitor-metrics div:last-child { border-right: 0; }
.monitor-metrics span { color: var(--text-dim); font-size: 8px; } .monitor-metrics span { color: var(--text-dim); font-size: 10px; }
.monitor-metrics strong { margin-top: 4px; color: #c6cbd0; 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 { 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%; } .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 { 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-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-row time { color: #626b74; }
.event-node { overflow: hidden; color: #bec4ca; text-overflow: ellipsis; white-space: nowrap; } .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; } .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; } .spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
.xml-view { position: absolute; inset: 0; display: flex; flex-direction: column; padding: 18px; background: #101215; } .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 > div { gap: 7px; }
.xml-header svg { color: var(--accent); } .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 { 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 strong { margin-top: 10px; font-size: 12px; }
.xml-error span { max-width: 460px; margin-top: 7px; color: #9b7774; font-size: 10px; text-align: center; } .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-bar > div { display: flex; align-items: center; gap: 13px; }
.status-valid, .status-error { display: inline-flex; align-items: center; gap: 4px; } .status-valid, .status-error { display: inline-flex; align-items: center; gap: 4px; }
.status-valid { color: #5fbf98; } .status-valid { color: #5fbf98; }
.status-error { color: #df746e; } .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; } .toast.error { border-color: #763b37; color: #f4b5b0; background: #351d1b; }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.workspace-grid { grid-template-columns: 205px minmax(0, 1fr) 265px; } .workspace-grid { grid-template-columns: 220px minmax(0, 1fr) 286px; }
.workspace-toolbar, .app-header { grid-template-columns: 205px minmax(120px, 1fr) auto; } .workspace-toolbar, .app-header { grid-template-columns: 220px minmax(120px, 1fr) auto; }
.brand-copy span { display: none; } .brand-copy span { display: none; }
.palette-items { grid-template-columns: 1fr; } .palette-items { grid-template-columns: 1fr; }
.monitor-body { grid-template-columns: minmax(260px, 42%) 92px minmax(220px, 1fr); } .monitor-body { grid-template-columns: minmax(260px, 42%) 92px minmax(220px, 1fr); }

View File

@@ -29,6 +29,7 @@ export type BehaviorNodeData = {
ports: Record<string, string> ports: Record<string, string>
notes: string notes: string
breakpoint: boolean breakpoint: boolean
xmlTag?: string
runtimeStatus?: RuntimeStatus runtimeStatus?: RuntimeStatus
[key: string]: unknown [key: string]: unknown
} }
@@ -36,11 +37,35 @@ export type BehaviorNodeData = {
export type BehaviorNode = Node<BehaviorNodeData, 'behavior'> export type BehaviorNode = Node<BehaviorNodeData, 'behavior'>
export type BehaviorEdge = Edge 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 { export interface BehaviorTreeDocument {
format: 'arborflow/project' format: 'arborflow/project'
version: 1 version: 1
title: string title: string
mainTreeId: string mainTreeId: string
activeTreeId?: string
trees?: BehaviorTreeGraph[]
nodeModels?: CustomNodeModel[]
nodes: BehaviorNode[] nodes: BehaviorNode[]
edges: BehaviorEdge[] edges: BehaviorEdge[]
} }