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

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
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 { 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('&', '&amp;')
@@ -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,
}
}