feat: dark/light theme, i18n (zh/en), inline node params with IN/OUT, root node, custom node creation, minimap & light mode contrast fixes

This commit is contained in:
2026-08-04 01:44:52 +08:00
parent f567d05a05
commit 2bdd58108a
17 changed files with 1936 additions and 233 deletions

View File

@@ -16,10 +16,13 @@ import type { BehaviorNode, CustomNodeModel, NodeCategory, NodeType } from '../t
export interface NodeDefinition {
type: NodeType
label: string
labelZh: string
description: string
descriptionZh: string
category: NodeCategory
icon: LucideIcon
defaultPorts?: Record<string, string>
defaultPortDirections?: Record<string, 'input' | 'output'>
}
export const CATEGORY_LABELS: Record<NodeCategory, string> = {
@@ -28,24 +31,26 @@ export const CATEGORY_LABELS: Record<NodeCategory, string> = {
action: '动作',
condition: '条件',
subtree: '子树',
root: '根',
}
export const NODE_DEFINITIONS: NodeDefinition[] = [
{ type: 'Sequence', label: 'Sequence', description: '依次执行,遇失败停止', category: 'control', icon: ListTree },
{ type: 'Fallback', label: 'Fallback', description: '依次尝试,遇成功停止', category: 'control', icon: GitBranch },
{ type: 'Parallel', label: 'Parallel', description: '并行执行多个子节点', category: 'control', icon: Split, defaultPorts: { success_count: '1', failure_count: '1' } },
{ type: 'ReactiveSequence', label: 'Reactive Sequence', description: '每次 tick 从首节点重评估', category: 'control', icon: RotateCcw },
{ type: 'ReactiveFallback', label: 'Reactive Fallback', description: '响应式回退控制', category: 'control', icon: RotateCcw },
{ type: 'Inverter', label: 'Inverter', description: '反转成功与失败结果', category: 'decorator', icon: Repeat2 },
{ type: 'RetryUntilSuccessful', label: 'Retry', description: '失败后重试指定次数', category: 'decorator', icon: Repeat2, defaultPorts: { num_attempts: '3' } },
{ type: 'Repeat', label: 'Repeat', description: '重复执行指定次数', category: 'decorator', icon: Repeat2, defaultPorts: { num_cycles: '3' } },
{ type: 'ForceSuccess', label: 'Force Success', description: '强制返回成功', category: 'decorator', icon: ShieldCheck },
{ type: 'ForceFailure', label: 'Force Failure', description: '强制返回失败', category: 'decorator', icon: ShieldX },
{ type: 'Action', label: 'Action', description: '调用 ROS 动作或业务逻辑', category: 'action', icon: Zap },
{ type: 'Condition', label: 'Condition', description: '检查黑板或环境条件', category: 'condition', icon: SquareFunction },
{ type: 'SubTree', label: 'SubTree', description: '调用另一棵行为树', category: 'subtree', icon: TreePine },
{ type: 'AlwaysSuccess', label: 'Always Success', description: '始终返回成功', category: 'action', icon: ShieldCheck },
{ type: 'AlwaysFailure', label: 'Always Failure', description: '始终返回失败', category: 'action', icon: ShieldX },
{ type: 'Sequence', label: 'Sequence', labelZh: '顺序执行', description: 'Execute children in order, stop on failure', descriptionZh: '依次执行子节点,遇失败停止', category: 'control', icon: ListTree },
{ type: 'Fallback', label: 'Fallback', labelZh: '选择回退', description: 'Try children in order, stop on success', descriptionZh: '依次尝试子节点,遇成功停止', category: 'control', icon: GitBranch },
{ type: 'Parallel', label: 'Parallel', labelZh: '并行执行', description: 'Execute multiple children in parallel', descriptionZh: '并行执行多个子节点', category: 'control', icon: Split, defaultPorts: { success_count: '1', failure_count: '1' }, defaultPortDirections: { success_count: 'input', failure_count: 'input' } },
{ type: 'ReactiveSequence', label: 'Reactive Sequence', labelZh: '响应序列', description: 'Re-evaluate from first child each tick', descriptionZh: '每次 tick 从头重新评估', category: 'control', icon: RotateCcw },
{ type: 'ReactiveFallback', label: 'Reactive Fallback', labelZh: '响应回退', description: 'Reactive fallback control node', descriptionZh: '响应式回退控制节点', category: 'control', icon: RotateCcw },
{ type: 'Inverter', label: 'Inverter', labelZh: '反转', description: 'Invert success/failure of child', descriptionZh: '反转子节点的成功/失败结果', category: 'decorator', icon: Repeat2 },
{ type: 'RetryUntilSuccessful', label: 'Retry', labelZh: '重试', description: 'Retry up to N times on failure', descriptionZh: '失败后重试指定次数', category: 'decorator', icon: Repeat2, defaultPorts: { num_attempts: '3' }, defaultPortDirections: { num_attempts: 'input' } },
{ type: 'Repeat', label: 'Repeat', labelZh: '重复', description: 'Repeat execution up to N cycles', descriptionZh: '重复执行指定次数', category: 'decorator', icon: Repeat2, defaultPorts: { num_cycles: '3' }, defaultPortDirections: { num_cycles: 'input' } },
{ type: 'ForceSuccess', label: 'Force Success', labelZh: '强制成功', description: 'Force return success', descriptionZh: '强制返回成功状态', category: 'decorator', icon: ShieldCheck },
{ type: 'ForceFailure', label: 'Force Failure', labelZh: '强制失败', description: 'Force return failure', descriptionZh: '强制返回失败状态', category: 'decorator', icon: ShieldX },
{ type: 'Action', label: 'Action', labelZh: '动作', description: 'Invoke a ROS action or business logic', descriptionZh: '调用 ROS 动作或业务逻辑', category: 'action', icon: Zap },
{ type: 'Condition', label: 'Condition', labelZh: '条件', description: 'Check blackboard or environment condition', descriptionZh: '检查黑板变量或环境条件', category: 'condition', icon: SquareFunction },
{ type: 'SubTree', label: 'SubTree', labelZh: '子树', description: 'Call another behavior tree', descriptionZh: '调用另一棵行为树', category: 'subtree', icon: TreePine },
{ type: 'AlwaysSuccess', label: 'Always Success', labelZh: '始终成功', description: 'Always return success', descriptionZh: '始终返回成功', category: 'action', icon: ShieldCheck },
{ type: 'AlwaysFailure', label: 'Always Failure', labelZh: '始终失败', description: 'Always return failure', descriptionZh: '始终返回失败', category: 'action', icon: ShieldX },
{ type: 'Root', label: 'Root', labelZh: '根节点', description: 'Root node of the behavior tree', descriptionZh: '行为树的根节点', category: 'control', icon: TreePine },
]
export const NODE_DEFINITION_MAP = new Map(NODE_DEFINITIONS.map((definition) => [definition.type, definition]))
@@ -53,7 +58,7 @@ export const NODE_DEFINITION_MAP = new Map(NODE_DEFINITIONS.map((definition) =>
export function createNode(type: NodeType, position = { x: 80, y: 80 }): BehaviorNode {
const definition = NODE_DEFINITION_MAP.get(type)!
const id = `node_${crypto.randomUUID().replaceAll('-', '').slice(0, 10)}`
const customName = type === 'Action' ? 'MyAction' : type === 'Condition' ? 'MyCondition' : type === 'SubTree' ? 'SubTreeID' : type
const customName = type === 'Root' ? 'Root' : type === 'Action' ? 'MyAction' : type === 'Condition' ? 'MyCondition' : type === 'SubTree' ? 'SubTreeID' : type
return {
id,
type: 'behavior',
@@ -64,6 +69,7 @@ export function createNode(type: NodeType, position = { x: 80, y: 80 }): Behavio
category: definition.category,
registrationName: customName,
ports: { ...(definition.defaultPorts || {}) },
portDirections: definition.defaultPortDirections ? { ...definition.defaultPortDirections } : undefined,
notes: '',
breakpoint: false,
},
@@ -91,3 +97,7 @@ export function maxChildren(type: NodeType) {
if (['Inverter', 'RetryUntilSuccessful', 'Repeat', 'ForceSuccess', 'ForceFailure'].includes(type)) return 1
return canHaveChildren(type) ? Number.POSITIVE_INFINITY : 0
}
export function isRoot(type: NodeType) {
return type === 'Root'
}

View File

@@ -8,31 +8,36 @@ export function createDemoDocument(): BehaviorTreeDocument {
mainTreeId: 'MainTree',
nodes: [
{
id: 'root_sequence', type: 'behavior', position: { x: 475, y: 60 },
id: 'root_node', type: 'behavior', position: { x: 475, y: 20 },
data: { label: 'Root', nodeType: 'Root', category: 'root', registrationName: 'Root', ports: {}, notes: '', breakpoint: false },
},
{
id: 'root_sequence', type: 'behavior', position: { x: 475, y: 140 },
data: { label: 'Mission Sequence', nodeType: 'Sequence', category: 'control', registrationName: 'Sequence', ports: {}, notes: '主任务流程', breakpoint: false },
},
{
id: 'battery_check', type: 'behavior', position: { x: 170, y: 240 },
data: { label: 'Battery OK?', nodeType: 'Condition', category: 'condition', registrationName: 'BatteryOK', ports: { min_level: '{battery_threshold}' }, notes: '', breakpoint: false },
id: 'battery_check', type: 'behavior', position: { x: 170, y: 320 },
data: { label: 'Battery OK?', nodeType: 'Condition', category: 'condition', registrationName: 'BatteryOK', ports: { min_level: '{battery_threshold}' }, portDirections: { min_level: 'input' }, notes: '', breakpoint: false },
},
{
id: 'navigation_fallback', type: 'behavior', position: { x: 475, y: 240 },
id: 'navigation_fallback', type: 'behavior', position: { x: 475, y: 320 },
data: { label: 'Navigate Safely', nodeType: 'Fallback', category: 'control', registrationName: 'Fallback', ports: {}, notes: '', breakpoint: false },
},
{
id: 'report_action', type: 'behavior', position: { x: 785, y: 240 },
data: { label: 'Report Result', nodeType: 'Action', category: 'action', registrationName: 'ReportResult', ports: { topic: '/mission/result' }, notes: '', breakpoint: false },
id: 'report_action', type: 'behavior', position: { x: 785, y: 320 },
data: { label: 'Report Result', nodeType: 'Action', category: 'action', registrationName: 'ReportResult', ports: { topic: '/mission/result' }, portDirections: { topic: 'input' }, notes: '', breakpoint: false },
},
{
id: 'at_goal', type: 'behavior', position: { x: 340, y: 430 },
data: { label: 'Already At Goal?', nodeType: 'Condition', category: 'condition', registrationName: 'AtGoal', ports: { target: '{goal}' }, notes: '', breakpoint: false },
id: 'at_goal', type: 'behavior', position: { x: 340, y: 520 },
data: { label: 'Already At Goal?', nodeType: 'Condition', category: 'condition', registrationName: 'AtGoal', ports: { target: '{goal}' }, portDirections: { target: 'input' }, notes: '', breakpoint: false },
},
{
id: 'navigate_action', type: 'behavior', position: { x: 610, y: 430 },
data: { label: 'Navigate To Pose', nodeType: 'Action', category: 'action', registrationName: 'NavigateToPose', ports: { goal: '{goal}', server_timeout: '5000' }, notes: '调用 Nav2 action server', breakpoint: true },
id: 'navigate_action', type: 'behavior', position: { x: 610, y: 520 },
data: { label: 'Navigate To Pose', nodeType: 'Action', category: 'action', registrationName: 'NavigateToPose', ports: { goal: '{goal}', server_timeout: '5000' }, portDirections: { goal: 'input', server_timeout: 'input' }, notes: '调用 Nav2 action server', breakpoint: true },
},
],
edges: [
{ id: 'e-root-seq', source: 'root_node', target: 'root_sequence', type: 'smoothstep' },
{ id: 'e-root-battery', source: 'root_sequence', target: 'battery_check', type: 'smoothstep' },
{ id: 'e-root-nav', source: 'root_sequence', target: 'navigation_fallback', type: 'smoothstep' },
{ id: 'e-root-report', source: 'root_sequence', target: 'report_action', type: 'smoothstep' },

View File

@@ -51,10 +51,23 @@ function renderTree(graph: BehaviorTreeGraph) {
})
children.forEach((items) => items.sort((a, b) => a.position.x - b.position.x))
// Skip Root nodes — their children become the XML root(s)
let entryRoot = roots[0]
if (entryRoot.data.nodeType === 'Root') {
const rootChildren = children.get(entryRoot.id) || []
if (rootChildren.length === 0) throw new Error(`${graph.id} 的根节点没有子节点`)
if (rootChildren.length > 1) throw new Error(`${graph.id} 的 Root 节点下有多个子节点,只能有一个`)
entryRoot = rootChildren[0]
}
const visited = new Set<string>()
const renderNode = (node: BehaviorNode, depth: number): string => {
if (visited.has(node.id)) throw new Error(`${graph.id} 中存在重复引用或环路`)
visited.add(node.id)
if (node.data.nodeType === 'Root') {
const rootChildren = children.get(node.id) || []
return rootChildren.map((child) => renderNode(child, depth)).join('\n')
}
const indent = ' '.repeat(depth)
const tag = nodeTag(node)
const attributes: Array<[string, string]> = []
@@ -64,13 +77,13 @@ function renderTree(graph: BehaviorTreeGraph) {
if (key.trim()) attributes.push([key.trim(), value])
})
const attrs = attributes.map(([key, value]) => ` ${key}="${escapeXml(value)}"`).join('')
const childNodes = children.get(node.id) || []
const childNodes = (children.get(node.id) || []).filter((c) => c.data.nodeType !== 'Root')
if (childNodes.length === 0) return `${indent}<${tag}${attrs}/>`
const content = childNodes.map((child) => renderNode(child, depth + 1)).join('\n')
return `${indent}<${tag}${attrs}>\n${content}\n${indent}</${tag}>`
}
return ` <BehaviorTree ID="${escapeXml(graph.id)}">\n${renderNode(roots[0], 2)}\n </BehaviorTree>`
return ` <BehaviorTree ID="${escapeXml(graph.id)}">\n${renderNode(entryRoot, 2)}\n </BehaviorTree>`
}
function renderNodeModels(models: CustomNodeModel[]) {