Compare commits

...

24 Commits

Author SHA1 Message Date
github-actions[bot]
c902dd7291 chore: release packages (#439)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-05 11:25:48 +08:00
YHH
0d33cf0097 feat(node-editor, blueprint): add group box and math/logic nodes (#438)
* feat(node-editor, blueprint): add group box and math/logic nodes

node-editor:
- Add visual group box for organizing nodes
- Dynamic bounds calculation based on node pin counts
- Groups auto-resize to wrap contained nodes
- Dragging group header moves all nodes together

blueprint:
- Add comprehensive math nodes (modulo, power, sqrt, trig, etc.)
- Add logic nodes (comparison, boolean, select)

docs:
- Update nodes.md with new math and logic nodes
- Add group feature documentation to editor-guide.md

* chore: remove unused debug and test scripts

Remove FBX animation debug scripts that are no longer needed:
- analyze-fbx, debug-*, test-*, verify-*, check-*, compare-*, trace-*, simple-fbx-test

Remove unused kill-dev-server.js from editor-app
2026-01-05 11:23:42 +08:00
yhh
45de62e453 docs(blueprint): add beta testing notice with QQ group 481923584 2026-01-04 18:15:44 +08:00
yhh
b983cbf87a docs(blueprint): fix editor interface description 2026-01-04 18:06:43 +08:00
yhh
34583b23af docs(blueprint): add editor user guide with download link
- Add Chinese and English editor guide for Cocos Creator blueprint plugin
- Add download link to GitHub Release in blueprint index pages
- Add editor guide to sidebar navigation
- Clarify blueprint files must be saved in resources directory
2026-01-04 17:59:43 +08:00
github-actions[bot]
f2c3a24404 chore: release packages (#437)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-04 17:27:10 +08:00
yhh
3bfb8a1c9b chore: add changeset for node-editor box selection feature 2026-01-04 17:24:20 +08:00
yhh
2ee8d87647 feat(node-editor): add box selection and variable node error states
- Add box selection (drag on empty canvas to select multiple nodes)
- Support Ctrl+drag for additive selection
- Add error state styling for invalid variable references (red border, warning icon)
- Support dynamic node title via data.displayTitle
- Support hiding inputs via data.hiddenInputs array
2026-01-04 17:22:20 +08:00
github-actions[bot]
2d537dc10c chore: release packages (#436)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-04 16:25:34 +08:00
YHH
c2acd14fce feat(blueprint): Add Component nodes + ECS refactor + node-editor fixes (#435)
* feat(blueprint): refactor BlueprintComponent as proper ECS Component

- Convert BlueprintComponent from interface to actual ECS Component class
- Add ready-to-use BlueprintSystem that extends EntitySystem
- Remove deprecated legacy APIs (createBlueprintSystem, etc.)
- Update all blueprint documentation (Chinese & English)
- Simplify user API: just add BlueprintSystem and BlueprintComponent

BREAKING CHANGE: BlueprintComponent is now a class extending Component,
not an interface. Use `new BlueprintComponent()` instead of
`createBlueprintComponentData()`.

* chore(blueprint): add changeset for ECS component refactor

* fix(node-editor): fix connections not rendering when node is collapsed

- getPinPosition now returns node header position when pin element is not found
- Added collapsedNodesKey to force re-render connections after collapse/expand
- Input pins connect to left side, output pins to right side of collapsed nodes

* chore(node-editor): add changeset for collapse connection fix

* feat(blueprint): add Add Component nodes for entity-component creation

- Add type-specific Add_ComponentName nodes via ComponentNodeGenerator
- Add generic ECS_AddComponent node for dynamic component creation
- Add ExecutionContext.getComponentClass() for component lookup
- Add registerComponentClass() helper for manual component registration
- Each Add node supports initial property values from @BlueprintProperty

* docs: update changeset with Add Component feature

* feat(blueprint): improve event nodes with Self output and auto-create BeginPlay

- Event Begin Play now outputs Self entity
- Event Tick now outputs Self entity + Delta Seconds
- Event End Play now outputs Self entity
- createEmptyBlueprint() now includes Event Begin Play by default
- Added menuPath to all event nodes for better organization

* docs: update changeset for auto component registration

* feat(blueprint): add variable nodes (Get/Set Variable)

* docs: update changeset with variable nodes
2026-01-04 16:22:59 +08:00
github-actions[bot]
7f631793d4 chore: release packages (#434)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-04 11:52:43 +08:00
YHH
2e84942ea1 feat(blueprint): refactor BlueprintComponent as proper ECS Component (#433)
* feat(blueprint): refactor BlueprintComponent as proper ECS Component

- Convert BlueprintComponent from interface to actual ECS Component class
- Add ready-to-use BlueprintSystem that extends EntitySystem
- Remove deprecated legacy APIs (createBlueprintSystem, etc.)
- Update all blueprint documentation (Chinese & English)
- Simplify user API: just add BlueprintSystem and BlueprintComponent

BREAKING CHANGE: BlueprintComponent is now a class extending Component,
not an interface. Use `new BlueprintComponent()` instead of
`createBlueprintComponentData()`.

* chore(blueprint): add changeset for ECS component refactor

* fix(node-editor): fix connections not rendering when node is collapsed

- getPinPosition now returns node header position when pin element is not found
- Added collapsedNodesKey to force re-render connections after collapse/expand
- Input pins connect to left side, output pins to right side of collapsed nodes

* chore(node-editor): add changeset for collapse connection fix

* feat(blueprint): add Add Component nodes for entity-component creation

- Add type-specific Add_ComponentName nodes via ComponentNodeGenerator
- Add generic ECS_AddComponent node for dynamic component creation
- Add ExecutionContext.getComponentClass() for component lookup
- Add registerComponentClass() helper for manual component registration
- Each Add node supports initial property values from @BlueprintProperty

* docs: update changeset with Add Component feature

* feat(blueprint): improve event nodes with Self output and auto-create BeginPlay

- Event Begin Play now outputs Self entity
- Event Tick now outputs Self entity + Delta Seconds
- Event End Play now outputs Self entity
- createEmptyBlueprint() now includes Event Begin Play by default
- Added menuPath to all event nodes for better organization
2026-01-04 11:50:16 +08:00
YHH
d0057333a7 feat(blueprint): refactor BlueprintComponent as proper ECS Component (#432)
- Convert BlueprintComponent from interface to actual ECS Component class
- Add ready-to-use BlueprintSystem that extends EntitySystem
- Remove deprecated legacy APIs (createBlueprintSystem, etc.)
- Update all blueprint documentation (Chinese & English)
- Simplify user API: just add BlueprintSystem and BlueprintComponent

BREAKING CHANGE: BlueprintComponent is now a class extending Component,
not an interface. Use `new BlueprintComponent()` instead of
`createBlueprintComponentData()`.
2026-01-04 09:53:28 +08:00
github-actions[bot]
54c8ff4d8f chore: release packages (#431)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-03 19:28:34 +08:00
YHH
caf3be72cd feat(blueprint, node-editor): 重构蓝图装饰器系统,添加 Shadow DOM 支持 (#430)
**blueprint**
- 移除 Reflect.getMetadata 依赖,装饰器要求显式指定类型
- 新增 ECS 节点:Entity、Component、Flow 控制节点
- 新增组件自动注册系统 (BlueprintExpose, BlueprintProperty, BlueprintMethod)
- 删除未实现的事件节点占位文件

**node-editor**
- 新增 injectNodeEditorStyles() 函数支持 Shadow DOM 样式注入
- 导出 nodeEditorCssText 用于手动样式注入
2026-01-03 19:24:34 +08:00
github-actions[bot]
ec3e449681 chore: release packages (#429)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-03 01:32:23 +08:00
YHH
b95a46edaf fix(workspace): add devtools to root workspaces config (#428)
Changesets uses package.json workspaces field, not pnpm-workspace.yaml.
This was causing the node-editor package to not be found during publish.
2026-01-03 01:23:26 +08:00
YHH
f493f2d6cc fix(node-editor): enable npm publishing (#427)
- Remove private flag from package.json
- Add node-editor to CI build list
2026-01-03 01:15:52 +08:00
YHH
6970394717 chore(changeset): add changeset for node-editor release (#426)
* refactor(node-editor): move to packages/devtools for standalone use

- Move @esengine/node-editor from packages/editor/plugins to packages/devtools
- Clean up dependencies: remove unused zustand, move react to peerDependencies
- Update pnpm-workspace.yaml to include packages/devtools/*
- Package is now standalone and can be used in Cocos/Laya plugins

* fix(changeset): remove node-editor from ignore list for publishing

* fix(changeset): remove invalid changeset file

* chore(changeset): add changeset for node-editor release
2026-01-03 01:02:09 +08:00
YHH
0e4b66aac4 fix(changeset): remove invalid changeset file (#425)
* refactor(node-editor): move to packages/devtools for standalone use

- Move @esengine/node-editor from packages/editor/plugins to packages/devtools
- Clean up dependencies: remove unused zustand, move react to peerDependencies
- Update pnpm-workspace.yaml to include packages/devtools/*
- Package is now standalone and can be used in Cocos/Laya plugins

* fix(changeset): remove node-editor from ignore list for publishing

* fix(changeset): remove invalid changeset file
2026-01-03 00:30:30 +08:00
YHH
7399e91a5b fix(changeset): remove node-editor from ignore list (#424)
* refactor(node-editor): move to packages/devtools for standalone use

- Move @esengine/node-editor from packages/editor/plugins to packages/devtools
- Clean up dependencies: remove unused zustand, move react to peerDependencies
- Update pnpm-workspace.yaml to include packages/devtools/*
- Package is now standalone and can be used in Cocos/Laya plugins

* fix(changeset): remove node-editor from ignore list for publishing
2026-01-02 22:05:38 +08:00
YHH
c84addaa0b refactor(node-editor): move to packages/devtools for standalone use (#423)
- Move @esengine/node-editor from packages/editor/plugins to packages/devtools
- Clean up dependencies: remove unused zustand, move react to peerDependencies
- Update pnpm-workspace.yaml to include packages/devtools/*
- Package is now standalone and can be used in Cocos/Laya plugins
2026-01-02 21:58:28 +08:00
github-actions[bot]
61da38faf5 chore: release packages (#422)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-02 17:23:17 +08:00
YHH
f333b81298 feat(server): add Schema validation system and binary encoding optimization (#421)
* feat(server): add distributed room support

- Add DistributedRoomManager for multi-server room management
- Add MemoryAdapter for testing and standalone mode
- Add RedisAdapter for production multi-server deployments
- Add LoadBalancedRouter with 5 load balancing strategies
- Add distributed config option to createServer
- Add $redirect message for cross-server player redirection
- Add failover mechanism for automatic room recovery
- Add room:migrated and server:draining event types
- Update documentation (zh/en)

* feat(server): add Schema validation system and binary encoding optimization

## Schema Validation System
- Add lightweight schema validation system (s.object, s.string, s.number, etc.)
- Support auto type inference with Infer<> generic
- Integrate schema validation into API/message handlers
- Add defineApiWithSchema and defineMsgWithSchema helpers

## Binary Encoding Optimization
- Add native WebSocket binary frame support via sendBinary()
- Add PacketType.Binary for efficient binary data transmission
- Optimize ECSRoom.broadcastBinary() to use native binary

## Architecture Improvements
- Extract BaseValidator to separate file to eliminate code duplication
- Add ECSRoom export to main index.ts for better discoverability
- Add Core.worldManager initialization check in ECSRoom constructor
- Remove deprecated validate field from ApiDefinition (use schema instead)

## Documentation
- Add Schema validation documentation in Chinese and English

* fix(rpc): resolve ESLint warnings with proper types

- Replace `any` with proper WebSocket type in connection.ts
- Add IncomingMessage type for request handling in index.ts
- Use Record<string, Handler> pattern instead of `any` casting
- Replace `any` with `unknown` in ProtocolDef and type inference
2026-01-02 17:18:13 +08:00
170 changed files with 15038 additions and 8563 deletions

View File

@@ -49,7 +49,6 @@
"@esengine/material-editor",
"@esengine/shader-editor",
"@esengine/world-streaming-editor",
"@esengine/node-editor",
"@esengine/sdk",
"@esengine/worker-generator",
"@esengine/engine"

View File

@@ -62,6 +62,7 @@ jobs:
pnpm --filter "@esengine/transaction" build
pnpm --filter "@esengine/cli" build
pnpm --filter "create-esengine-server" build
pnpm --filter "@esengine/node-editor" build
- name: Create Release Pull Request or Publish
id: changesets

View File

@@ -232,6 +232,7 @@ export default defineConfig({
translations: { en: 'Blueprint' },
items: [
{ label: '概述', slug: 'modules/blueprint', translations: { en: 'Overview' } },
{ label: '编辑器使用指南', slug: 'modules/blueprint/editor-guide', translations: { en: 'Editor Guide' } },
{ label: '虚拟机 API', slug: 'modules/blueprint/vm', translations: { en: 'VM API' } },
{ label: '自定义节点', slug: 'modules/blueprint/custom-nodes', translations: { en: 'Custom Nodes' } },
{ label: '内置节点', slug: 'modules/blueprint/nodes', translations: { en: 'Built-in Nodes' } },

View File

@@ -28,13 +28,13 @@ const MyNodeTemplate: BlueprintNodeTemplate = {
## Implementing Node Executor
```typescript
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
import { INodeExecutor, RegisterNode, BlueprintNode, ExecutionContext, ExecutionResult } from '@esengine/blueprint';
@RegisterNode(MyNodeTemplate)
class MyNodeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
// Get input
const value = context.getInput<number>(node.id, 'value');
// Get input (using evaluateInput)
const value = context.evaluateInput(node.id, 'value', 0) as number;
// Execute logic
const result = value * 2;
@@ -100,29 +100,58 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
};
```
## Example: Input Handler Node
## Example: ECS Component Operation Node
```typescript
const InputMoveTemplate: BlueprintNodeTemplate = {
type: 'InputMove',
title: 'Get Movement Input',
category: 'input',
inputs: [],
outputs: [
{ name: 'direction', type: 'vector2', direction: 'output' }
import type { Entity } from '@esengine/ecs-framework';
import { BlueprintNodeTemplate, BlueprintNode } from '@esengine/blueprint';
import { ExecutionContext, ExecutionResult } from '@esengine/blueprint';
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
// Custom heal node
const HealEntityTemplate: BlueprintNodeTemplate = {
type: 'HealEntity',
title: 'Heal Entity',
category: 'gameplay',
color: '#22aa22',
description: 'Heal an entity with HealthComponent',
keywords: ['heal', 'health', 'restore'],
menuPath: ['Gameplay', 'Combat', 'Heal Entity'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'entity', type: 'entity', displayName: 'Target' },
{ name: 'amount', type: 'float', displayName: 'Amount', defaultValue: 10 }
],
isPure: true
outputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'newHealth', type: 'float', displayName: 'New Health' }
]
};
@RegisterNode(InputMoveTemplate)
class InputMoveExecutor implements INodeExecutor {
@RegisterNode(HealEntityTemplate)
class HealEntityExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const input = context.scene.services.get(InputServiceToken);
const direction = {
x: input.getAxis('horizontal'),
y: input.getAxis('vertical')
};
return { outputs: { direction } };
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
if (!entity || entity.isDestroyed) {
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
}
// Get HealthComponent
const health = entity.components.find(c =>
(c.constructor as any).__componentName__ === 'Health'
) as any;
if (health) {
health.current = Math.min(health.current + amount, health.max);
return {
outputs: { newHealth: health.current },
nextExec: 'exec'
};
}
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
}
}
```

View File

@@ -0,0 +1,415 @@
---
title: "Blueprint Editor User Guide"
description: "Complete guide for using the Cocos Creator Blueprint Visual Scripting Editor"
---
This guide covers how to use the Blueprint Visual Scripting Editor in Cocos Creator.
## Download & Installation
### Download
> **Beta Testing**: The blueprint editor is currently in beta. An activation code is required.
> Please join QQ Group **481923584** and message the group owner to get your activation code.
Download the latest version from GitHub Release:
**[Download Cocos Node Editor v1.0.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.0.0)**
### Installation Steps
1. Extract `cocos-node-editor.zip` to your project's `extensions` directory:
```
your-project/
├── assets/
├── extensions/
│ └── cocos-node-editor/ ← Extract here
└── ...
```
2. Restart Cocos Creator
3. Confirm the plugin is enabled via **Extensions → Extension Manager**
4. Open the editor via **Panel → Node Editor**
## Interface Overview
- **Toolbar** - Located at the top, contains New, Open, Save, Undo, Redo operations
- **Variables Panel** - Located at the top-left, for defining and managing blueprint variables
- **Canvas Area** - Main area for placing and connecting nodes
- **Node Menu** - Right-click on empty canvas to open, lists all available nodes by category
## Canvas Operations
| Operation | Method |
|-----------|--------|
| Pan canvas | Middle-click drag / Alt + Left-click drag |
| Zoom canvas | Mouse wheel |
| Open node menu | Right-click on empty space |
| Box select nodes | Drag on empty canvas |
| Additive select | Ctrl + Drag |
| Delete selected | Delete key |
## Node Operations
### Adding Nodes
1. **Drag from Node Panel** - Drag nodes from the left panel onto the canvas
2. **Right-click Menu** - Right-click on empty canvas space to select nodes
### Connecting Nodes
1. Drag from an output pin to an input pin
2. Compatible pins will highlight
3. Release to complete the connection
**Pin Type Reference:**
| Pin Color | Type | Description |
|-----------|------|-------------|
| White ▶ | Exec | Execution flow (controls order) |
| Cyan ◆ | Entity | Entity reference |
| Purple ◆ | Component | Component reference |
| Light Blue ◆ | String | String value |
| Green ◆ | Number | Numeric value |
| Red ◆ | Boolean | Boolean value |
| Gray ◆ | Any | Any type |
### Deleting Connections
Click a connection line to select it, then press Delete.
## Node Types Reference
### Event Nodes
Event nodes are entry points for blueprint execution, triggered when specific events occur.
| Node | Trigger | Outputs |
|------|---------|---------|
| **Event BeginPlay** | When blueprint starts | Exec, Self (Entity) |
| **Event Tick** | Every frame | Exec, Delta Time |
| **Event EndPlay** | When blueprint stops | Exec |
**Example: Print message on game start**
```
[Event BeginPlay] ──Exec──→ [Print]
└─ Message: "Game Started!"
```
### Entity Nodes
Nodes for operating on ECS entities.
| Node | Function | Inputs | Outputs |
|------|----------|--------|---------|
| **Get Self** | Get current entity | - | Entity |
| **Create Entity** | Create new entity | Exec, Name | Exec, Entity |
| **Destroy Entity** | Destroy entity | Exec, Entity | Exec |
| **Find Entity By Name** | Find by name | Name | Entity |
| **Find Entities By Tag** | Find by tag | Tag | Entity[] |
| **Is Valid** | Check entity validity | Entity | Boolean |
| **Get/Set Entity Name** | Get/Set name | Entity | String |
| **Set Active** | Set active state | Exec, Entity, Active | Exec |
**Example: Create new entity**
```
[Event BeginPlay] ──→ [Create Entity] ──→ [Add Component]
└─ Name: "Bullet" └─ Type: Transform
```
### Component Nodes
Access and manipulate ECS components.
| Node | Function |
|------|----------|
| **Has Component** | Check if entity has component |
| **Get Component** | Get component instance |
| **Add Component** | Add component to entity |
| **Remove Component** | Remove component |
| **Get/Set Property** | Get/Set component property |
**Example: Modify Transform component**
```
[Get Self] ─Entity─→ [Get Component: Transform] ─Component─→ [Set Property]
├─ Property: x
└─ Value: 100
```
### Flow Control Nodes
Nodes that control execution flow.
#### Branch
Conditional branching, similar to if/else.
```
┌─ True ──→ [DoSomething]
[Branch]─┤
└─ False ─→ [DoOtherThing]
```
#### Sequence
Execute multiple branches in order.
```
┌─ Then 0 ──→ [Step1]
[Sequence]─┼─ Then 1 ──→ [Step2]
└─ Then 2 ──→ [Step3]
```
#### For Loop
Execute a specified number of times.
```
[For Loop] ─Loop Body─→ [Execute each iteration]
└─ Completed ────→ [Execute after loop ends]
```
| Input | Description |
|-------|-------------|
| First Index | Starting index |
| Last Index | Ending index |
| Output | Description |
|--------|-------------|
| Loop Body | Execute each iteration |
| Index | Current index |
| Completed | Execute after loop ends |
#### For Each
Iterate over array elements.
#### While Loop
Loop while condition is true.
#### Do Once
Execute only once, skip afterwards.
#### Flip Flop
Alternate between A and B outputs each execution.
#### Gate
Control whether execution passes through via Open/Close/Toggle.
### Time Nodes
| Node | Function | Output Type |
|------|----------|-------------|
| **Delay** | Delay execution by specified time | Exec |
| **Get Delta Time** | Get frame delta time | Number |
| **Get Time** | Get total runtime | Number |
**Example: Execute after 2 second delay**
```
[Event BeginPlay] ──→ [Delay] ──→ [Print]
└─ Duration: 2.0 └─ "Executed after 2s"
```
### Math Nodes
| Node | Function |
|------|----------|
| **Add / Subtract / Multiply / Divide** | Arithmetic operations |
| **Abs** | Absolute value |
| **Clamp** | Clamp to range |
| **Lerp** | Linear interpolation |
| **Min / Max** | Minimum/Maximum value |
| **Random Range** | Random number |
| **Sin / Cos / Tan** | Trigonometric functions |
### Debug Nodes
| Node | Function |
|------|----------|
| **Print** | Output to console |
## Variable System
Variables store and share data within a blueprint.
### Creating Variables
1. Click the **+** button in the Variables panel
2. Enter variable name
3. Select variable type
4. Set default value (optional)
### Using Variables
- **Drag to canvas** - Creates Get or Set node
- **Get Node** - Read variable value
- **Set Node** - Write variable value
### Variable Types
| Type | Description | Default |
|------|-------------|---------|
| Boolean | Boolean value | false |
| Number | Numeric value | 0 |
| String | String value | "" |
| Entity | Entity reference | null |
| Vector2 | 2D vector | (0, 0) |
| Vector3 | 3D vector | (0, 0, 0) |
### Variable Node Error State
If you delete a variable but nodes still reference it:
- Nodes display a **red border** and **warning icon**
- You need to recreate the variable or delete these nodes
## Node Grouping
You can organize multiple nodes into a visual group box to help manage complex blueprints.
### Creating a Group
1. Box-select or Ctrl+click to select multiple nodes (at least 2)
2. Right-click on the selected nodes
3. Choose **Create Group**
4. A group box will automatically wrap all selected nodes
### Group Operations
| Action | Method |
|--------|--------|
| Move group | Drag the group header, all nodes move together |
| Ungroup | Right-click on group box → **Ungroup** |
### Features
- **Dynamic sizing**: Group box automatically resizes to wrap all nodes
- **Independent movement**: You can move nodes within the group individually, and the box adjusts
- **Editor only**: Groups are purely visual organization, no runtime impact
## Keyboard Shortcuts
| Shortcut | Function |
|----------|----------|
| `Ctrl + S` | Save blueprint |
| `Ctrl + Z` | Undo |
| `Ctrl + Shift + Z` | Redo |
| `Ctrl + C` | Copy selected nodes |
| `Ctrl + X` | Cut selected nodes |
| `Ctrl + V` | Paste nodes |
| `Delete` | Delete selected items |
| `Ctrl + A` | Select all |
## Save & Load
### Saving Blueprints
1. Click the **Save** button in the toolbar
2. Choose save location (**must be saved in `assets/resources` directory**, otherwise Cocos Creator cannot load dynamically)
3. File extension is `.blueprint.json`
> **Important**: Blueprint files must be placed in the `resources` directory for runtime loading via `cc.resources.load()`.
### Loading Blueprints
1. Click the **Open** button in the toolbar
2. Select a `.blueprint.json` file
### Blueprint File Format
Blueprints are saved as JSON, compatible with `@esengine/blueprint` runtime:
```json
{
"version": 1,
"type": "blueprint",
"metadata": {
"name": "PlayerController",
"description": "Player control logic"
},
"variables": [],
"nodes": [],
"connections": []
}
```
## Practical Examples
### Example 1: Movement Control
Move entity every frame:
```
[Event Tick] ─Exec─→ [Get Self] ─Entity─→ [Get Component: Transform]
[Get Delta Time] ▼
│ [Set Property: x]
│ │
[Multiply] ◄──────────────┘
└─ Speed: 100
```
### Example 2: Health System
Check death after taking damage:
```
[On Damage Event] ─→ [Get Component: Health] ─→ [Get Property: current]
[Subtract]
[Set Property: current]
┌─ True ─→ [Destroy Self]
[Branch]─┤
└─ False ─→ (continue)
[Less Or Equal]
current <= 0
```
### Example 3: Delayed Spawning
Spawn an enemy every 2 seconds:
```
[Event BeginPlay] ─→ [Do N Times] ─Loop─→ [Delay: 2.0] ─→ [Create Entity: Enemy]
└─ N: 10
```
## Troubleshooting
### Q: Nodes won't connect?
Check if pin types are compatible. Execution pins (white) can only connect to execution pins. Data pins need matching types.
### Q: Blueprint not executing?
1. Ensure entity has `BlueprintComponent` attached
2. Ensure scene has `BlueprintSystem` added
3. Check if `autoStart` is `true`
### Q: How to debug?
Use **Print** nodes to output variable values to the console.
## Next Steps
- [ECS Node Reference](./nodes) - Complete node list
- [Custom Nodes](./custom-nodes) - Create custom nodes
- [Runtime Integration](./vm) - Blueprint VM API
- [Examples](./examples) - More game logic examples

View File

@@ -3,85 +3,127 @@ title: "Examples"
description: "ECS integration and best practices"
---
## Player Control Blueprint
## Complete Game Integration Example
```typescript
// Define input handling node
const InputMoveTemplate: BlueprintNodeTemplate = {
type: 'InputMove',
title: 'Get Movement Input',
category: 'input',
inputs: [],
outputs: [
{ name: 'direction', type: 'vector2', direction: 'output' }
],
isPure: true
};
import { Scene, Core, Component, ECSComponent } from '@esengine/ecs-framework';
import {
BlueprintSystem,
BlueprintComponent,
BlueprintExpose,
BlueprintProperty,
BlueprintMethod
} from '@esengine/blueprint';
@RegisterNode(InputMoveTemplate)
class InputMoveExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const input = context.scene.services.get(InputServiceToken);
const direction = {
x: input.getAxis('horizontal'),
y: input.getAxis('vertical')
};
return { outputs: { direction } };
// 1. Define game components
@ECSComponent('Player')
@BlueprintExpose({ displayName: 'Player', category: 'gameplay' })
export class PlayerComponent extends Component {
@BlueprintProperty({ displayName: 'Move Speed', type: 'float' })
moveSpeed: number = 5;
@BlueprintProperty({ displayName: 'Score', type: 'int' })
score: number = 0;
@BlueprintMethod({ displayName: 'Add Score' })
addScore(points: number): void {
this.score += points;
}
}
@ECSComponent('Health')
@BlueprintExpose({ displayName: 'Health', category: 'gameplay' })
export class HealthComponent extends Component {
@BlueprintProperty({ displayName: 'Current Health' })
current: number = 100;
@BlueprintProperty({ displayName: 'Max Health' })
max: number = 100;
@BlueprintMethod({ displayName: 'Heal' })
heal(amount: number): void {
this.current = Math.min(this.current + amount, this.max);
}
@BlueprintMethod({ displayName: 'Take Damage' })
takeDamage(amount: number): boolean {
this.current -= amount;
return this.current <= 0;
}
}
// 2. Initialize game
async function initGame() {
const scene = new Scene();
// Add blueprint system
scene.addSystem(new BlueprintSystem());
Core.setScene(scene);
// 3. Create player
const player = scene.createEntity('Player');
player.addComponent(new PlayerComponent());
player.addComponent(new HealthComponent());
// Add blueprint control
const blueprint = new BlueprintComponent();
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
player.addComponent(blueprint);
}
```
## State Switching Logic
## Custom Node Example
```typescript
// Implement state machine logic in blueprint
const stateBlueprint = createEmptyBlueprint('PlayerState');
import type { Entity } from '@esengine/ecs-framework';
import {
BlueprintNodeTemplate,
BlueprintNode,
ExecutionContext,
ExecutionResult,
INodeExecutor,
RegisterNode
} from '@esengine/blueprint';
// Add state variable
stateBlueprint.variables.push({
name: 'currentState',
type: 'string',
defaultValue: 'idle',
scope: 'instance'
});
// Check state transitions in Tick event
// ... implemented via node connections
```
## Damage Handling System
```typescript
// Custom damage node
const ApplyDamageTemplate: BlueprintNodeTemplate = {
type: 'ApplyDamage',
title: 'Apply Damage',
category: 'combat',
color: '#aa2222',
description: 'Apply damage to entity with Health component',
keywords: ['damage', 'hurt', 'attack'],
menuPath: ['Combat', 'Apply Damage'],
inputs: [
{ name: 'exec', type: 'exec', direction: 'input', isExec: true },
{ name: 'target', type: 'entity', direction: 'input' },
{ name: 'amount', type: 'number', direction: 'input', defaultValue: 10 }
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'target', type: 'entity', displayName: 'Target' },
{ name: 'amount', type: 'float', displayName: 'Damage', defaultValue: 10 }
],
outputs: [
{ name: 'exec', type: 'exec', direction: 'output', isExec: true },
{ name: 'killed', type: 'boolean', direction: 'output' }
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'killed', type: 'bool', displayName: 'Killed' }
]
};
@RegisterNode(ApplyDamageTemplate)
class ApplyDamageExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const target = context.getInput<Entity>(node.id, 'target');
const amount = context.getInput<number>(node.id, 'amount');
const target = context.evaluateInput(node.id, 'target', context.entity) as Entity;
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
if (!target || target.isDestroyed) {
return { outputs: { killed: false }, nextExec: 'exec' };
}
const health = target.components.find(c =>
(c.constructor as any).__componentName__ === 'Health'
) as any;
const health = target.getComponent(HealthComponent);
if (health) {
health.current -= amount;
const killed = health.current <= 0;
return {
outputs: { killed },
nextExec: 'exec'
};
return { outputs: { killed }, nextExec: 'exec' };
}
return { outputs: { killed: false }, nextExec: 'exec' };
@@ -132,7 +174,8 @@ vm.maxStepsPerFrame = 1000;
```typescript
// Enable debug mode for execution logs
vm.debug = true;
const blueprint = entity.getComponent(BlueprintComponent);
blueprint.debug = true;
// Use Print nodes for intermediate values
// Set breakpoints in editor

View File

@@ -1,414 +1,163 @@
---
title: "Blueprint Visual Scripting"
description: "Visual scripting system deeply integrated with ECS framework"
---
`@esengine/blueprint` provides a full-featured visual scripting system supporting node-based programming, event-driven execution, and blueprint composition.
`@esengine/blueprint` provides a visual scripting system deeply integrated with the ECS framework, supporting node-based programming to control entity behavior.
## Installation
## Editor Download
> **Beta Testing**: The blueprint editor is currently in beta. An activation code is required.
> Please join QQ Group **481923584** and message the group owner to get your activation code.
Blueprint Editor Plugin for Cocos Creator:
**[Download Cocos Node Editor v1.0.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.0.0)**
For detailed usage instructions, see [Editor User Guide](./editor-guide).
## Runtime Installation
```bash
npm install @esengine/blueprint
```
## Core Features
- **Deep ECS Integration** - Built-in Entity and Component operation nodes
- **Auto-generated Component Nodes** - Use decorators to mark components, auto-generate Get/Set/Call nodes
- **Runtime Blueprint Execution** - Efficient virtual machine executes blueprint logic
## Quick Start
### 1. Add Blueprint System
```typescript
import { Scene, Core } from '@esengine/ecs-framework';
import { BlueprintSystem } from '@esengine/blueprint';
// Create scene and add blueprint system
const scene = new Scene();
scene.addSystem(new BlueprintSystem());
// Set scene
Core.setScene(scene);
```
### 2. Add Blueprint to Entity
```typescript
import { BlueprintComponent } from '@esengine/blueprint';
// Create entity
const player = scene.createEntity('Player');
// Add blueprint component
const blueprint = new BlueprintComponent();
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
blueprint.autoStart = true;
player.addComponent(blueprint);
```
### 3. Mark Components (Auto-generate Blueprint Nodes)
```typescript
import {
createBlueprintSystem,
createBlueprintComponentData,
NodeRegistry,
RegisterNode
BlueprintExpose,
BlueprintProperty,
BlueprintMethod
} from '@esengine/blueprint';
import { Component, ECSComponent } from '@esengine/ecs-framework';
// Create blueprint system
const blueprintSystem = createBlueprintSystem(scene);
@ECSComponent('Health')
@BlueprintExpose({ displayName: 'Health', category: 'gameplay' })
export class HealthComponent extends Component {
@BlueprintProperty({ displayName: 'Current Health', type: 'float' })
current: number = 100;
// Load blueprint asset
const blueprint = await loadBlueprintAsset('player.bp');
@BlueprintProperty({ displayName: 'Max Health', type: 'float' })
max: number = 100;
// Create blueprint component data
const componentData = createBlueprintComponentData();
componentData.blueprintAsset = blueprint;
@BlueprintMethod({
displayName: 'Heal',
params: [{ name: 'amount', type: 'float' }]
})
heal(amount: number): void {
this.current = Math.min(this.current + amount, this.max);
}
// Update in game loop
function gameLoop(dt: number) {
blueprintSystem.process(entities, dt);
@BlueprintMethod({ displayName: 'Take Damage' })
takeDamage(amount: number): boolean {
this.current -= amount;
return this.current <= 0;
}
}
```
## Core Concepts
After marking, the following nodes will appear in the blueprint editor:
- **Get Health** - Get Health component
- **Get Current Health** - Get current property
- **Set Current Health** - Set current property
- **Heal** - Call heal method
- **Take Damage** - Call takeDamage method
### Blueprint Asset Structure
## ECS Integration Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Core.update() │
│ ↓ │
│ Scene.updateSystems() │
│ ↓ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ BlueprintSystem │ │
│ │ │ │
│ │ Matcher.all(BlueprintComponent) │ │
│ │ ↓ │ │
│ │ process(entities) → blueprint.tick() for each entity │ │
│ │ ↓ │ │
│ │ BlueprintVM.tick(dt) │ │
│ │ ↓ │ │
│ │ Execute Event/ECS/Flow Nodes │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## Node Types
| Category | Description | Color |
|----------|-------------|-------|
| `event` | Event nodes (BeginPlay, Tick, EndPlay) | Red |
| `entity` | ECS entity operations | Blue |
| `component` | ECS component access | Cyan |
| `flow` | Flow control (Branch, Sequence, Loop) | Gray |
| `math` | Math operations | Green |
| `time` | Time utilities (Delay, GetDeltaTime) | Cyan |
| `debug` | Debug utilities (Print) | Gray |
## Blueprint Asset Structure
Blueprints are saved as `.bp` files:
```typescript
interface BlueprintAsset {
version: number; // Format version
type: 'blueprint'; // Asset type
metadata: BlueprintMetadata; // Metadata
variables: BlueprintVariable[]; // Variable definitions
nodes: BlueprintNode[]; // Node instances
connections: BlueprintConnection[]; // Connections
version: number;
type: 'blueprint';
metadata: {
name: string;
description?: string;
};
variables: BlueprintVariable[];
nodes: BlueprintNode[];
connections: BlueprintConnection[];
}
```
### Node Categories
| Category | Description | Color |
|----------|-------------|-------|
| `event` | Event nodes (entry points) | Red |
| `flow` | Flow control | Gray |
| `entity` | Entity operations | Blue |
| `component` | Component access | Cyan |
| `math` | Math operations | Green |
| `logic` | Logic operations | Red |
| `variable` | Variable access | Purple |
| `time` | Time utilities | Cyan |
| `debug` | Debug utilities | Gray |
### Pin Types
Nodes connect through pins:
```typescript
interface BlueprintPinDefinition {
name: string; // Pin name
type: PinDataType; // Data type
direction: 'input' | 'output';
isExec?: boolean; // Execution pin
defaultValue?: unknown;
}
type PinDataType =
| 'exec' // Execution flow
| 'boolean' // Boolean
| 'number' // Number
| 'string' // String
| 'vector2' // 2D vector
| 'vector3' // 3D vector
| 'entity' // Entity reference
| 'component' // Component reference
| 'any'; // Any type
```
### Variable Scopes
```typescript
type VariableScope =
| 'local' // Per execution
| 'instance' // Per entity
| 'global'; // Shared globally
```
## Virtual Machine API
### BlueprintVM
The virtual machine executes blueprint graphs:
```typescript
import { BlueprintVM } from '@esengine/blueprint';
const vm = new BlueprintVM(blueprintAsset, entity, scene);
vm.start(); // Start (triggers BeginPlay)
vm.tick(deltaTime); // Update (triggers Tick)
vm.stop(); // Stop (triggers EndPlay)
vm.pause();
vm.resume();
// Trigger events
vm.triggerEvent('EventCollision', { other: otherEntity });
vm.triggerCustomEvent('OnDamage', { amount: 50 });
// Debug mode
vm.debug = true;
```
### Execution Context
```typescript
interface ExecutionContext {
blueprint: BlueprintAsset;
entity: Entity;
scene: IScene;
deltaTime: number;
time: number;
getInput<T>(nodeId: string, pinName: string): T;
setOutput(nodeId: string, pinName: string, value: unknown): void;
getVariable<T>(name: string): T;
setVariable(name: string, value: unknown): void;
}
```
### Execution Result
```typescript
interface ExecutionResult {
outputs?: Record<string, unknown>; // Output values
nextExec?: string | null; // Next exec pin
delay?: number; // Delay execution (ms)
yield?: boolean; // Pause until next frame
error?: string; // Error message
}
```
## Custom Nodes
### Define Node Template
```typescript
import { BlueprintNodeTemplate } from '@esengine/blueprint';
const MyNodeTemplate: BlueprintNodeTemplate = {
type: 'MyCustomNode',
title: 'My Custom Node',
category: 'custom',
description: 'A custom node example',
keywords: ['custom', 'example'],
inputs: [
{ name: 'exec', type: 'exec', direction: 'input', isExec: true },
{ name: 'value', type: 'number', direction: 'input', defaultValue: 0 }
],
outputs: [
{ name: 'exec', type: 'exec', direction: 'output', isExec: true },
{ name: 'result', type: 'number', direction: 'output' }
]
};
```
### Implement Node Executor
```typescript
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
@RegisterNode(MyNodeTemplate)
class MyNodeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = context.getInput<number>(node.id, 'value');
const result = value * 2;
return {
outputs: { result },
nextExec: 'exec'
};
}
}
```
### Registration Methods
```typescript
// Method 1: Decorator
@RegisterNode(MyNodeTemplate)
class MyNodeExecutor implements INodeExecutor { ... }
// Method 2: Manual registration
NodeRegistry.instance.register(MyNodeTemplate, new MyNodeExecutor());
```
## Node Registry
```typescript
import { NodeRegistry } from '@esengine/blueprint';
const registry = NodeRegistry.instance;
const allTemplates = registry.getAllTemplates();
const mathNodes = registry.getTemplatesByCategory('math');
const results = registry.searchTemplates('add');
if (registry.has('MyCustomNode')) { ... }
```
## Built-in Nodes
### Event Nodes
| Node | Description |
|------|-------------|
| `EventBeginPlay` | Triggered on blueprint start |
| `EventTick` | Triggered every frame |
| `EventEndPlay` | Triggered on blueprint stop |
| `EventCollision` | Triggered on collision |
| `EventInput` | Triggered on input |
| `EventTimer` | Triggered by timer |
### Time Nodes
| Node | Description |
|------|-------------|
| `Delay` | Delay execution |
| `GetDeltaTime` | Get frame delta |
| `GetTime` | Get total runtime |
### Math Nodes
| Node | Description |
|------|-------------|
| `Add`, `Subtract`, `Multiply`, `Divide` | Basic operations |
| `Abs`, `Clamp`, `Lerp`, `Min`, `Max` | Utility functions |
### Debug Nodes
| Node | Description |
|------|-------------|
| `Print` | Print to console |
## Blueprint Composition
### Blueprint Fragments
Encapsulate reusable logic as fragments:
```typescript
import { createFragment } from '@esengine/blueprint';
const healthFragment = createFragment('HealthSystem', {
inputs: [
{ name: 'damage', type: 'number', internalNodeId: 'input1', internalPinName: 'value' }
],
outputs: [
{ name: 'isDead', type: 'boolean', internalNodeId: 'output1', internalPinName: 'value' }
],
graph: { nodes: [...], connections: [...], variables: [...] }
});
```
### Compose Blueprints
```typescript
import { createComposer, FragmentRegistry } from '@esengine/blueprint';
// Register fragments
FragmentRegistry.instance.register('health', healthFragment);
FragmentRegistry.instance.register('movement', movementFragment);
// Create composer
const composer = createComposer('PlayerBlueprint');
// Add fragments to slots
composer.addFragment(healthFragment, 'slot1', { position: { x: 0, y: 0 } });
composer.addFragment(movementFragment, 'slot2', { position: { x: 400, y: 0 } });
// Connect slots
composer.connect('slot1', 'onDeath', 'slot2', 'disable');
// Validate
const validation = composer.validate();
if (!validation.isValid) {
console.error(validation.errors);
}
// Compile to blueprint
const blueprint = composer.compile();
```
## Trigger System
### Define Trigger Conditions
```typescript
import { TriggerCondition, TriggerDispatcher } from '@esengine/blueprint';
const lowHealthCondition: TriggerCondition = {
type: 'comparison',
left: { type: 'variable', name: 'health' },
operator: '<',
right: { type: 'constant', value: 20 }
};
```
### Use Trigger Dispatcher
```typescript
const dispatcher = new TriggerDispatcher();
dispatcher.register('lowHealth', lowHealthCondition, (context) => {
context.triggerEvent('OnLowHealth');
});
dispatcher.evaluate(context);
```
## ECS Integration
### Using Blueprint System
```typescript
import { createBlueprintSystem } from '@esengine/blueprint';
class GameScene {
private blueprintSystem: BlueprintSystem;
initialize() {
this.blueprintSystem = createBlueprintSystem(this.scene);
}
update(dt: number) {
this.blueprintSystem.process(this.entities, dt);
}
}
```
### Triggering Blueprint Events
```typescript
import { triggerBlueprintEvent, triggerCustomBlueprintEvent } from '@esengine/blueprint';
triggerBlueprintEvent(entity, 'Collision', { other: otherEntity });
triggerCustomBlueprintEvent(entity, 'OnPickup', { item: itemEntity });
```
## Serialization
### Save Blueprint
```typescript
import { validateBlueprintAsset } from '@esengine/blueprint';
function saveBlueprint(blueprint: BlueprintAsset, path: string): void {
if (!validateBlueprintAsset(blueprint)) {
throw new Error('Invalid blueprint structure');
}
const json = JSON.stringify(blueprint, null, 2);
fs.writeFileSync(path, json);
}
```
### Load Blueprint
```typescript
async function loadBlueprint(path: string): Promise<BlueprintAsset> {
const json = await fs.readFile(path, 'utf-8');
const asset = JSON.parse(json);
if (!validateBlueprintAsset(asset)) {
throw new Error('Invalid blueprint file');
}
return asset;
}
```
## Best Practices
1. **Use fragments for reusable logic**
2. **Choose appropriate variable scopes**
- `local`: Temporary calculations
- `instance`: Entity state (e.g., health)
- `global`: Game-wide state
3. **Avoid infinite loops** - VM has max steps per frame (default 1000)
4. **Debug techniques**
- Enable `vm.debug = true` for execution logs
- Use Print nodes for intermediate values
5. **Performance optimization**
- Pure nodes (`isPure: true`) cache outputs
- Avoid heavy computation in Tick
## Documentation
- [Virtual Machine API](./vm) - BlueprintVM execution and context
- [Custom Nodes](./custom-nodes) - Creating custom nodes
- [Built-in Nodes](./nodes) - Built-in node reference
- [Blueprint Composition](./composition) - Fragments and composer
- [Examples](./examples) - ECS integration and best practices
## Documentation Navigation
- [Editor User Guide](./editor-guide) - Cocos Creator Blueprint Editor tutorial
- [Virtual Machine API](./vm) - BlueprintVM and ECS integration
- [ECS Node Reference](./nodes) - Built-in ECS operation nodes
- [Custom Nodes](./custom-nodes) - Create custom ECS nodes
- [Blueprint Composition](./composition) - Fragment reuse
- [Examples](./examples) - ECS game logic examples

View File

@@ -1,107 +1,192 @@
---
title: "Built-in Nodes"
description: "Blueprint built-in node reference"
title: "ECS Node Reference"
description: "Blueprint built-in ECS operation nodes"
---
## Event Nodes
Lifecycle events as blueprint entry points:
| Node | Description |
|------|-------------|
| `EventBeginPlay` | Triggered when blueprint starts |
| `EventTick` | Triggered each frame |
| `EventTick` | Triggered each frame, receives deltaTime |
| `EventEndPlay` | Triggered when blueprint stops |
| `EventCollision` | Triggered on collision |
| `EventInput` | Triggered on input event |
| `EventTimer` | Triggered by timer |
| `EventMessage` | Triggered by custom message |
## Entity Nodes
ECS entity operations:
| Node | Description | Type |
|------|-------------|------|
| `Get Self` | Get entity owning this blueprint | Pure |
| `Create Entity` | Create new entity in scene | Execution |
| `Destroy Entity` | Destroy specified entity | Execution |
| `Destroy Self` | Destroy self entity | Execution |
| `Is Valid` | Check if entity is valid | Pure |
| `Get Entity Name` | Get entity name | Pure |
| `Set Entity Name` | Set entity name | Execution |
| `Get Entity Tag` | Get entity tag | Pure |
| `Set Entity Tag` | Set entity tag | Execution |
| `Set Active` | Set entity active state | Execution |
| `Is Active` | Check if entity is active | Pure |
| `Find Entity By Name` | Find entity by name | Pure |
| `Find Entities By Tag` | Find all entities by tag | Pure |
| `Get Entity ID` | Get entity unique ID | Pure |
| `Find Entity By ID` | Find entity by ID | Pure |
## Component Nodes
ECS component operations:
| Node | Description | Type |
|------|-------------|------|
| `Has Component` | Check if entity has specified component | Pure |
| `Get Component` | Get component from entity | Pure |
| `Get All Components` | Get all components from entity | Pure |
| `Remove Component` | Remove component | Execution |
| `Get Component Property` | Get component property value | Pure |
| `Set Component Property` | Set component property value | Execution |
| `Get Component Type` | Get component type name | Pure |
| `Get Owner Entity` | Get owning entity from component | Pure |
## Flow Control Nodes
Control execution flow:
| Node | Description |
|------|-------------|
| `Branch` | Conditional branch (if/else) |
| `Sequence` | Execute multiple outputs in sequence |
| `ForLoop` | Loop execution |
| `WhileLoop` | Conditional loop |
| `DoOnce` | Execute only once |
| `FlipFlop` | Alternate between two branches |
| `For Loop` | Loop execution |
| `For Each` | Iterate array |
| `While Loop` | Conditional loop |
| `Do Once` | Execute only once |
| `Flip Flop` | Alternate between two branches |
| `Gate` | Toggleable execution gate |
## Time Nodes
| Node | Description |
|------|-------------|
| `Delay` | Delay execution |
| `GetDeltaTime` | Get frame delta time |
| `GetTime` | Get runtime |
| `SetTimer` | Set timer |
| `ClearTimer` | Clear timer |
| Node | Description | Type |
|------|-------------|------|
| `Delay` | Delay execution | Execution |
| `Get Delta Time` | Get frame delta time | Pure |
| `Get Time` | Get total runtime | Pure |
## Math Nodes
Basic Operations:
| Node | Description |
|------|-------------|
| `Add` | Addition |
| `Subtract` | Subtraction |
| `Multiply` | Multiplication |
| `Divide` | Division |
| `Add` / `Subtract` / `Multiply` / `Divide` | Basic arithmetic |
| `Modulo` | Modulo operation (%) |
| `Negate` | Negate value |
| `Abs` | Absolute value |
| `Clamp` | Clamp to range |
| `Lerp` | Linear interpolation |
| `Sign` | Sign (+1, 0, -1) |
| `Min` / `Max` | Minimum/Maximum |
| `Sin` / `Cos` | Trigonometric functions |
| `Clamp` | Clamp to range |
| `Wrap` | Wrap value to range |
Power & Roots:
| Node | Description |
|------|-------------|
| `Power` | Power (A^B) |
| `Sqrt` | Square root |
| `Power` | Power |
Rounding:
| Node | Description |
|------|-------------|
| `Floor` | Round down |
| `Ceil` | Round up |
| `Round` | Round to nearest |
Trigonometry:
| Node | Description |
|------|-------------|
| `Sin` / `Cos` / `Tan` | Sine/Cosine/Tangent |
| `Asin` / `Acos` / `Atan` | Inverse trig functions |
| `Atan2` | Two-argument arctangent |
| `DegToRad` / `RadToDeg` | Degree/Radian conversion |
Interpolation:
| Node | Description |
|------|-------------|
| `Lerp` | Linear interpolation |
| `InverseLerp` | Inverse linear interpolation |
Random:
| Node | Description |
|------|-------------|
| `Random Range` | Random float in range |
| `Random Int` | Random integer in range |
## Logic Nodes
| Node | Description |
|------|-------------|
| `And` | Logical AND |
| `Or` | Logical OR |
| `Not` | Logical NOT |
| `Equal` | Equality comparison |
| `NotEqual` | Inequality comparison |
| `Greater` | Greater than comparison |
| `Less` | Less than comparison |
## Vector Nodes
Comparison:
| Node | Description |
|------|-------------|
| `MakeVector2` | Create 2D vector |
| `BreakVector2` | Break 2D vector |
| `VectorAdd` | Vector addition |
| `VectorSubtract` | Vector subtraction |
| `VectorMultiply` | Vector multiplication |
| `VectorLength` | Vector length |
| `VectorNormalize` | Vector normalization |
| `VectorDistance` | Vector distance |
| `Equal` | Equal (==) |
| `Not Equal` | Not equal (!=) |
| `Greater Than` | Greater than (>) |
| `Greater Or Equal` | Greater than or equal (>=) |
| `Less Than` | Less than (<) |
| `Less Or Equal` | Less than or equal (<=) |
| `In Range` | Check if value is in range |
## Entity Nodes
Logical Operations:
| Node | Description |
|------|-------------|
| `GetSelf` | Get current entity |
| `GetComponent` | Get component |
| `HasComponent` | Check component |
| `AddComponent` | Add component |
| `RemoveComponent` | Remove component |
| `SpawnEntity` | Create entity |
| `DestroyEntity` | Destroy entity |
| `AND` | Logical AND |
| `OR` | Logical OR |
| `NOT` | Logical NOT |
| `XOR` | Exclusive OR |
| `NAND` | NOT AND |
## Variable Nodes
Utility:
| Node | Description |
|------|-------------|
| `GetVariable` | Get variable value |
| `SetVariable` | Set variable value |
| `Is Null` | Check if value is null |
| `Select` | Choose A or B based on condition (ternary) |
## Debug Nodes
| Node | Description |
|------|-------------|
| `Print` | Print to console |
| `DrawDebugLine` | Draw debug line |
| `DrawDebugPoint` | Draw debug point |
| `Breakpoint` | Debug breakpoint |
## Auto-generated Component Nodes
Components marked with `@BlueprintExpose` decorator auto-generate nodes:
```typescript
@ECSComponent('Transform')
@BlueprintExpose({ displayName: 'Transform', category: 'core' })
export class TransformComponent extends Component {
@BlueprintProperty({ displayName: 'X Position' })
x: number = 0;
@BlueprintProperty({ displayName: 'Y Position' })
y: number = 0;
@BlueprintMethod({ displayName: 'Translate' })
translate(dx: number, dy: number): void {
this.x += dx;
this.y += dy;
}
}
```
Generated nodes:
- **Get Transform** - Get Transform component
- **Get X Position** / **Set X Position** - Access x property
- **Get Y Position** / **Set Y Position** - Access y property
- **Translate** - Call translate method

View File

@@ -45,7 +45,7 @@ interface ExecutionContext {
time: number; // Total runtime
// Get input value
getInput<T>(nodeId: string, pinName: string): T;
evaluateInput(nodeId: string, pinName: string, defaultValue: unknown): unknown;
// Set output value
setOutput(nodeId: string, pinName: string, value: unknown): void;
@@ -70,35 +70,33 @@ interface ExecutionResult {
## ECS Integration
### Using Blueprint System
### Using Built-in Blueprint System
```typescript
import { createBlueprintSystem } from '@esengine/blueprint';
import { Scene, Core } from '@esengine/ecs-framework';
import { BlueprintSystem, BlueprintComponent } from '@esengine/blueprint';
class GameScene {
private blueprintSystem: BlueprintSystem;
// Add blueprint system to scene
const scene = new Scene();
scene.addSystem(new BlueprintSystem());
Core.setScene(scene);
initialize() {
this.blueprintSystem = createBlueprintSystem(this.scene);
}
update(dt: number) {
// Process all entities with blueprint components
this.blueprintSystem.process(this.entities, dt);
}
}
// Add blueprint to entity
const entity = scene.createEntity('Player');
const blueprint = new BlueprintComponent();
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
entity.addComponent(blueprint);
```
### Triggering Blueprint Events
```typescript
import { triggerBlueprintEvent, triggerCustomBlueprintEvent } from '@esengine/blueprint';
// Trigger built-in event
triggerBlueprintEvent(entity, 'Collision', { other: otherEntity });
// Trigger custom event
triggerCustomBlueprintEvent(entity, 'OnPickup', { item: itemEntity });
// Get blueprint component from entity and trigger events
const blueprint = entity.getComponent(BlueprintComponent);
if (blueprint?.vm) {
blueprint.vm.triggerEvent('EventCollision', { other: otherEntity });
blueprint.vm.triggerCustomEvent('OnPickup', { item: itemEntity });
}
```
## Serialization

View File

@@ -0,0 +1,441 @@
---
title: "Distributed Rooms"
description: "Multi-server room management with DistributedRoomManager"
---
## Overview
Distributed room support allows multiple server instances to share a room registry, enabling cross-server player routing and failover.
```
┌─────────────────────────────────────────────────────────┐
│ Server A Server B Server C │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Room 1 │ │ Room 3 │ │ Room 5 │ │
│ │ Room 2 │ │ Room 4 │ │ Room 6 │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ └─────────────────┼─────────────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ IDistributedAdapter │ │
│ │ (Redis / Memory) │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
## Quick Start
### Single Server Mode (Testing)
```typescript
import {
DistributedRoomManager,
MemoryAdapter,
Room
} from '@esengine/server';
// Define room type
class GameRoom extends Room {
maxPlayers = 4;
}
// Create adapter and manager
const adapter = new MemoryAdapter();
const manager = new DistributedRoomManager(adapter, {
serverId: 'server-1',
serverAddress: 'localhost',
serverPort: 3000
}, (conn, type, data) => conn.send(JSON.stringify({ type, data })));
// Register room type
manager.define('game', GameRoom);
// Start manager
await manager.start();
// Distributed join/create room
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
if ('redirect' in result) {
// Player should connect to another server
console.log(`Redirect to: ${result.redirect}`);
} else {
// Player joined local room
const { room, player } = result;
}
// Graceful shutdown
await manager.stop(true);
```
### Multi-Server Mode (Production)
```typescript
import Redis from 'ioredis';
import { DistributedRoomManager, RedisAdapter } from '@esengine/server';
const adapter = new RedisAdapter({
factory: () => new Redis({
host: 'redis.example.com',
port: 6379
}),
prefix: 'game:',
serverTtl: 30,
snapshotTtl: 86400
});
const manager = new DistributedRoomManager(adapter, {
serverId: process.env.SERVER_ID,
serverAddress: process.env.PUBLIC_IP,
serverPort: 3000,
heartbeatInterval: 5000,
snapshotInterval: 30000,
enableFailover: true,
capacity: 100
}, sendFn);
```
## DistributedRoomManager
### Configuration Options
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `serverId` | `string` | required | Unique server identifier |
| `serverAddress` | `string` | required | Public address for client connections |
| `serverPort` | `number` | required | Server port |
| `heartbeatInterval` | `number` | `5000` | Heartbeat interval (ms) |
| `snapshotInterval` | `number` | `30000` | State snapshot interval, 0 to disable |
| `migrationTimeout` | `number` | `10000` | Room migration timeout |
| `enableFailover` | `boolean` | `true` | Enable automatic failover |
| `capacity` | `number` | `100` | Max rooms on this server |
### Lifecycle Methods
#### start()
Start the distributed room manager. Connects to adapter, registers server, starts heartbeat.
```typescript
await manager.start();
```
#### stop(graceful?)
Stop the manager. If `graceful=true`, marks server as draining and saves all room snapshots.
```typescript
await manager.stop(true);
```
### Routing Methods
#### joinOrCreateDistributed()
Join or create a room with distributed awareness. Returns `{ room, player }` for local rooms or `{ redirect: string }` for remote rooms.
```typescript
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
if ('redirect' in result) {
// Client should redirect to another server
res.json({ redirect: result.redirect });
} else {
// Player joined local room
const { room, player } = result;
}
```
#### route()
Route a player to the appropriate room/server.
```typescript
const result = await manager.route({
roomType: 'game',
playerId: 'p1'
});
switch (result.type) {
case 'local': // Room is on this server
break;
case 'redirect': // Room is on another server
// result.serverAddress contains target server
break;
case 'create': // No room exists, need to create
break;
case 'unavailable': // Cannot find or create room
// result.reason contains error message
break;
}
```
### State Management
#### saveSnapshot()
Manually save a room's state snapshot.
```typescript
await manager.saveSnapshot(roomId);
```
#### restoreFromSnapshot()
Restore a room from its saved snapshot.
```typescript
const success = await manager.restoreFromSnapshot(roomId);
```
### Query Methods
#### getServers()
Get all online servers.
```typescript
const servers = await manager.getServers();
```
#### queryDistributedRooms()
Query rooms across all servers.
```typescript
const rooms = await manager.queryDistributedRooms({
roomType: 'game',
hasSpace: true,
notLocked: true
});
```
## IDistributedAdapter
Interface for distributed backends. Implement this to add support for Redis, message queues, etc.
### Built-in Adapters
#### MemoryAdapter
In-memory implementation for testing and single-server mode.
```typescript
const adapter = new MemoryAdapter({
serverTtl: 15000, // Server offline after no heartbeat (ms)
enableTtlCheck: true, // Enable automatic TTL checking
ttlCheckInterval: 5000 // TTL check interval (ms)
});
```
#### RedisAdapter
Redis-based implementation for production multi-server deployments.
```typescript
import Redis from 'ioredis';
import { RedisAdapter } from '@esengine/server';
const adapter = new RedisAdapter({
factory: () => new Redis('redis://localhost:6379'),
prefix: 'game:', // Key prefix (default: 'dist:')
serverTtl: 30, // Server TTL in seconds (default: 30)
roomTtl: 0, // Room TTL, 0 = never expire (default: 0)
snapshotTtl: 86400, // Snapshot TTL in seconds (default: 24h)
channel: 'game:events' // Pub/Sub channel (default: 'distributed:events')
});
```
**RedisAdapter Configuration:**
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `factory` | `() => RedisClient` | required | Redis client factory (lazy connection) |
| `prefix` | `string` | `'dist:'` | Key prefix for all Redis keys |
| `serverTtl` | `number` | `30` | Server TTL in seconds |
| `roomTtl` | `number` | `0` | Room TTL in seconds, 0 = no expiry |
| `snapshotTtl` | `number` | `86400` | Snapshot TTL in seconds |
| `channel` | `string` | `'distributed:events'` | Pub/Sub channel name |
**Features:**
- Server registry with automatic heartbeat TTL
- Room registry with cross-server lookup
- State snapshots with configurable TTL
- Pub/Sub for cross-server events
- Distributed locks using Redis SET NX
### Custom Adapters
```typescript
import type { IDistributedAdapter } from '@esengine/server';
class MyAdapter implements IDistributedAdapter {
// Lifecycle
async connect(): Promise<void> { }
async disconnect(): Promise<void> { }
isConnected(): boolean { return true; }
// Server Registry
async registerServer(server: ServerRegistration): Promise<void> { }
async unregisterServer(serverId: string): Promise<void> { }
async heartbeat(serverId: string): Promise<void> { }
async getServers(): Promise<ServerRegistration[]> { return []; }
// Room Registry
async registerRoom(room: RoomRegistration): Promise<void> { }
async unregisterRoom(roomId: string): Promise<void> { }
async queryRooms(query: RoomQuery): Promise<RoomRegistration[]> { return []; }
async findAvailableRoom(roomType: string): Promise<RoomRegistration | null> { return null; }
// State Snapshots
async saveSnapshot(snapshot: RoomSnapshot): Promise<void> { }
async loadSnapshot(roomId: string): Promise<RoomSnapshot | null> { return null; }
// Pub/Sub
async publish(event: DistributedEvent): Promise<void> { }
async subscribe(pattern: string, handler: Function): Promise<() => void> { return () => {}; }
// Distributed Locks
async acquireLock(key: string, ttlMs: number): Promise<boolean> { return true; }
async releaseLock(key: string): Promise<void> { }
}
```
## Player Routing Flow
```
Client Server A Server B
│ │ │
│─── joinOrCreate ────────►│ │
│ │ │
│ │── findAvailableRoom() ───►│
│ │◄──── room on Server B ────│
│ │ │
│◄─── redirect: B:3001 ────│ │
│ │ │
│───────────────── connect to Server B ───────────────►│
│ │ │
│◄─────────────────────────────── joined ─────────────│
```
## Event Types
The distributed system publishes these events:
| Event | Description |
|-------|-------------|
| `server:online` | Server came online |
| `server:offline` | Server went offline |
| `server:draining` | Server is draining |
| `room:created` | Room was created |
| `room:disposed` | Room was disposed |
| `room:updated` | Room info updated |
| `room:message` | Cross-server room message |
| `room:migrated` | Room migrated to another server |
| `player:joined` | Player joined room |
| `player:left` | Player left room |
## Best Practices
1. **Use Unique Server IDs** - Use hostname, container ID, or UUID
2. **Configure Proper Heartbeat** - Balance between freshness and network overhead
3. **Enable Snapshots for Stateful Rooms** - Ensure room state survives server restarts
4. **Handle Redirects Gracefully** - Client should reconnect to target server
```typescript
// Client handling redirect
if (response.redirect) {
await client.disconnect();
await client.connect(response.redirect);
await client.joinRoom(roomId);
}
```
5. **Use Distributed Locks** - Prevent race conditions in joinOrCreate
## Using createServer Integration
The simplest way to use distributed rooms is through `createServer`'s `distributed` config:
```typescript
import { createServer } from '@esengine/server';
import { RedisAdapter, Room } from '@esengine/server';
import Redis from 'ioredis';
class GameRoom extends Room {
maxPlayers = 4;
}
const server = await createServer({
port: 3000,
distributed: {
enabled: true,
adapter: new RedisAdapter({ factory: () => new Redis() }),
serverId: 'server-1',
serverAddress: 'ws://192.168.1.100',
serverPort: 3000,
enableFailover: true,
capacity: 100
}
});
server.define('game', GameRoom);
await server.start();
```
When clients call the `JoinRoom` API, the server will automatically:
1. Find available rooms (local or remote)
2. If room is on another server, send `$redirect` message to client
3. Client receives redirect and connects to target server
## Load Balancing
Use `LoadBalancedRouter` for server selection:
```typescript
import { LoadBalancedRouter, createLoadBalancedRouter } from '@esengine/server';
// Using factory function
const router = createLoadBalancedRouter('least-players');
// Or create directly
const router = new LoadBalancedRouter({
strategy: 'least-rooms', // Select server with fewest rooms
preferLocal: true // Prefer local server
});
// Available strategies
// - 'round-robin': Round robin selection
// - 'least-rooms': Fewest rooms
// - 'least-players': Fewest players
// - 'random': Random selection
// - 'weighted': Weighted by capacity usage
```
## Failover
When a server goes offline with `enableFailover` enabled, the system will automatically:
1. Detect server offline (via heartbeat timeout)
2. Query all rooms on that server
3. Use distributed lock to prevent multiple servers recovering same room
4. Restore room state from snapshot
5. Publish `room:migrated` event to notify other servers
```typescript
// Ensure periodic snapshots
const manager = new DistributedRoomManager(adapter, {
serverId: 'server-1',
serverAddress: 'localhost',
serverPort: 3000,
snapshotInterval: 30000, // Save snapshot every 30 seconds
enableFailover: true // Enable failover
}, sendFn);
```
## Future Releases
- Redis Cluster support
- More load balancing strategies (geo-location, latency-aware)

View File

@@ -147,6 +147,7 @@ service.on('chat', (data) => {
- [Client Usage](/en/modules/network/client/) - NetworkPlugin, components and systems
- [Server Side](/en/modules/network/server/) - GameServer and Room management
- [Distributed Rooms](/en/modules/network/distributed/) - Multi-server room management and player routing
- [State Sync](/en/modules/network/sync/) - Interpolation and snapshot buffering
- [Client Prediction](/en/modules/network/prediction/) - Input prediction and server reconciliation
- [Area of Interest (AOI)](/en/modules/network/aoi/) - View filtering and bandwidth optimization

View File

@@ -266,6 +266,122 @@ class GameRoom extends Room {
}
```
## Schema Validation
Use the built-in Schema validation system for runtime type validation:
### Basic Usage
```typescript
import { s, defineApiWithSchema } from '@esengine/server'
// Define schema
const MoveSchema = s.object({
x: s.number(),
y: s.number(),
speed: s.number().optional()
})
// Auto type inference
type Move = s.infer<typeof MoveSchema> // { x: number; y: number; speed?: number }
// Use schema to define API (auto validation)
export default defineApiWithSchema(MoveSchema, {
handler(req, ctx) {
// req is validated, type-safe
console.log(req.x, req.y)
}
})
```
### Validator Types
| Type | Example | Description |
|------|---------|-------------|
| `s.string()` | `s.string().min(1).max(50)` | String with length constraints |
| `s.number()` | `s.number().min(0).int()` | Number with range and integer constraints |
| `s.boolean()` | `s.boolean()` | Boolean |
| `s.literal()` | `s.literal('admin')` | Literal type |
| `s.object()` | `s.object({ name: s.string() })` | Object |
| `s.array()` | `s.array(s.number())` | Array |
| `s.enum()` | `s.enum(['a', 'b'] as const)` | Enum |
| `s.union()` | `s.union([s.string(), s.number()])` | Union type |
| `s.record()` | `s.record(s.any())` | Record type |
### Modifiers
```typescript
// Optional field
s.string().optional()
// Default value
s.number().default(0)
// Nullable
s.string().nullable()
// String validation
s.string().min(1).max(100).email().url().regex(/^[a-z]+$/)
// Number validation
s.number().min(0).max(100).int().positive()
// Array validation
s.array(s.string()).min(1).max(10).nonempty()
// Object validation
s.object({ ... }).strict() // No extra fields allowed
s.object({ ... }).partial() // All fields optional
s.object({ ... }).pick('name', 'age') // Pick fields
s.object({ ... }).omit('password') // Omit fields
```
### Message Validation
```typescript
import { s, defineMsgWithSchema } from '@esengine/server'
const InputSchema = s.object({
keys: s.array(s.string()),
timestamp: s.number()
})
export default defineMsgWithSchema(InputSchema, {
handler(msg, ctx) {
// msg is validated
console.log(msg.keys, msg.timestamp)
}
})
```
### Manual Validation
```typescript
import { s, parse, safeParse, createGuard } from '@esengine/server'
const UserSchema = s.object({
name: s.string(),
age: s.number().int().min(0)
})
// Throws on error
const user = parse(UserSchema, data)
// Returns result object
const result = safeParse(UserSchema, data)
if (result.success) {
console.log(result.data)
} else {
console.error(result.error)
}
// Type guard
const isUser = createGuard(UserSchema)
if (isUser(data)) {
// data is User type
}
```
## Protocol Definition
Define shared types in `src/shared/protocol.ts`:

View File

@@ -28,13 +28,13 @@ const MyNodeTemplate: BlueprintNodeTemplate = {
## 实现节点执行器
```typescript
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
import { INodeExecutor, RegisterNode, BlueprintNode, ExecutionContext, ExecutionResult } from '@esengine/blueprint';
@RegisterNode(MyNodeTemplate)
class MyNodeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
// 获取输入
const value = context.getInput<number>(node.id, 'value');
// 获取输入(使用 evaluateInput
const value = context.evaluateInput(node.id, 'value', 0) as number;
// 执行逻辑
const result = value * 2;
@@ -100,29 +100,58 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
};
```
## 实际示例:输入处理节点
## 实际示例:ECS 组件操作节点
```typescript
const InputMoveTemplate: BlueprintNodeTemplate = {
type: 'InputMove',
title: 'Get Movement Input',
category: 'input',
inputs: [],
outputs: [
{ name: 'direction', type: 'vector2', direction: 'output' }
import type { Entity } from '@esengine/ecs-framework';
import { BlueprintNodeTemplate, BlueprintNode } from '@esengine/blueprint';
import { ExecutionContext, ExecutionResult } from '@esengine/blueprint';
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
// 自定义治疗节点
const HealEntityTemplate: BlueprintNodeTemplate = {
type: 'HealEntity',
title: 'Heal Entity',
category: 'gameplay',
color: '#22aa22',
description: 'Heal an entity with HealthComponent',
keywords: ['heal', 'health', 'restore'],
menuPath: ['Gameplay', 'Combat', 'Heal Entity'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'entity', type: 'entity', displayName: 'Target' },
{ name: 'amount', type: 'float', displayName: 'Amount', defaultValue: 10 }
],
isPure: true
outputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'newHealth', type: 'float', displayName: 'New Health' }
]
};
@RegisterNode(InputMoveTemplate)
class InputMoveExecutor implements INodeExecutor {
@RegisterNode(HealEntityTemplate)
class HealEntityExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const input = context.scene.services.get(InputServiceToken);
const direction = {
x: input.getAxis('horizontal'),
y: input.getAxis('vertical')
};
return { outputs: { direction } };
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
if (!entity || entity.isDestroyed) {
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
}
// 获取 HealthComponent
const health = entity.components.find(c =>
(c.constructor as any).__componentName__ === 'Health'
) as any;
if (health) {
health.current = Math.min(health.current + amount, health.max);
return {
outputs: { newHealth: health.current },
nextExec: 'exec'
};
}
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
}
}
```

View File

@@ -0,0 +1,415 @@
---
title: "蓝图编辑器使用指南"
description: "Cocos Creator 蓝图可视化脚本编辑器完整使用教程"
---
本指南介绍如何在 Cocos Creator 中使用蓝图可视化脚本编辑器。
## 下载与安装
### 下载
> **内测中**:蓝图编辑器目前处于内测阶段,需要激活码才能使用。
> 请加入 QQ 群 **481923584** 后私聊群主获取激活码。
从 GitHub Release 下载最新版本:
**[下载 Cocos Node Editor v1.0.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.0.0)**
### 安装步骤
1. 解压 `cocos-node-editor.zip` 到项目的 `extensions` 目录:
```
your-project/
├── assets/
├── extensions/
│ └── cocos-node-editor/ ← 解压到这里
└── ...
```
2. 重启 Cocos Creator
3. 通过菜单 **扩展 → 扩展管理器** 确认插件已启用
4. 通过菜单 **面板 → Node Editor** 打开编辑器
## 界面介绍
- **工具栏** - 位于顶部,包含新建、打开、保存、撤销、重做等操作
- **变量面板** - 位于左上角,用于定义和管理蓝图变量
- **画布区域** - 主区域,用于放置和连接节点
- **节点菜单** - 右键点击画布空白处打开,按分类列出所有可用节点
## 画布操作
| 操作 | 方式 |
|------|------|
| 平移画布 | 鼠标中键拖拽 / Alt + 左键拖拽 |
| 缩放画布 | 鼠标滚轮 |
| 打开节点菜单 | 右键点击空白处 |
| 框选多个节点 | 在空白处拖拽 |
| 追加框选 | Ctrl + 拖拽 |
| 删除选中 | Delete 键 |
## 节点操作
### 添加节点
1. **从节点面板拖拽** - 将节点从左侧面板拖到画布
2. **右键菜单** - 右键点击画布空白处,选择节点
### 连接节点
1. 从输出引脚拖拽到输入引脚
2. 兼容类型的引脚会高亮显示
3. 松开鼠标完成连接
**引脚类型说明:**
| 引脚颜色 | 类型 | 说明 |
|---------|------|------|
| 白色 ▶ | Exec | 执行流程(控制执行顺序) |
| 青色 ◆ | Entity | 实体引用 |
| 紫色 ◆ | Component | 组件引用 |
| 浅蓝 ◆ | String | 字符串 |
| 绿色 ◆ | Number | 数值 |
| 红色 ◆ | Boolean | 布尔值 |
| 灰色 ◆ | Any | 任意类型 |
### 删除连接
点击连接线选中,按 Delete 键删除。
## 节点类型详解
### 事件节点 (Event)
事件节点是蓝图的入口点,当特定事件发生时触发执行。
| 节点 | 触发时机 | 输出 |
|------|---------|------|
| **Event BeginPlay** | 蓝图开始运行时 | Exec, Self (实体) |
| **Event Tick** | 每帧执行 | Exec, Delta Time |
| **Event EndPlay** | 蓝图停止时 | Exec |
**示例:游戏开始时打印消息**
```
[Event BeginPlay] ──Exec──→ [Print]
└─ Message: "游戏开始!"
```
### 实体节点 (Entity)
操作 ECS 实体的节点。
| 节点 | 功能 | 输入 | 输出 |
|------|------|------|------|
| **Get Self** | 获取当前实体 | - | Entity |
| **Create Entity** | 创建新实体 | Exec, Name | Exec, Entity |
| **Destroy Entity** | 销毁实体 | Exec, Entity | Exec |
| **Find Entity By Name** | 按名称查找 | Name | Entity |
| **Find Entities By Tag** | 按标签查找 | Tag | Entity[] |
| **Is Valid** | 检查实体有效性 | Entity | Boolean |
| **Get/Set Entity Name** | 获取/设置名称 | Entity | String |
| **Set Active** | 设置激活状态 | Exec, Entity, Active | Exec |
**示例:创建新实体**
```
[Event BeginPlay] ──→ [Create Entity] ──→ [Add Component]
└─ Name: "Bullet" └─ Type: Transform
```
### 组件节点 (Component)
访问和操作 ECS 组件。
| 节点 | 功能 |
|------|------|
| **Has Component** | 检查实体是否有指定组件 |
| **Get Component** | 获取组件实例 |
| **Add Component** | 添加组件到实体 |
| **Remove Component** | 移除组件 |
| **Get/Set Property** | 获取/设置组件属性 |
**示例:修改 Transform 组件**
```
[Get Self] ─Entity─→ [Get Component: Transform] ─Component─→ [Set Property]
├─ Property: x
└─ Value: 100
```
### 流程控制节点 (Flow)
控制执行流程的节点。
#### Branch (分支)
条件判断,类似 if/else。
```
┌─ True ──→ [DoSomething]
[Branch]─┤
└─ False ─→ [DoOtherThing]
```
#### Sequence (序列)
按顺序执行多个分支。
```
┌─ Then 0 ──→ [Step1]
[Sequence]─┼─ Then 1 ──→ [Step2]
└─ Then 2 ──→ [Step3]
```
#### For Loop (循环)
循环执行指定次数。
```
[For Loop] ─Loop Body─→ [每次迭代执行]
└─ Completed ────→ [循环结束后执行]
```
| 输入 | 说明 |
|------|------|
| First Index | 起始索引 |
| Last Index | 结束索引 |
| 输出 | 说明 |
|------|------|
| Loop Body | 每次迭代执行 |
| Index | 当前索引 |
| Completed | 循环结束后执行 |
#### For Each (遍历)
遍历数组元素。
#### While Loop (条件循环)
当条件为真时持续循环。
#### Do Once (单次执行)
只执行一次,之后跳过。
#### Flip Flop (交替执行)
每次执行时交替触发 A 和 B 输出。
#### Gate (门)
可通过 Open/Close/Toggle 控制是否允许执行通过。
### 时间节点 (Time)
| 节点 | 功能 | 输出类型 |
|------|------|---------|
| **Delay** | 延迟指定时间后继续执行 | Exec |
| **Get Delta Time** | 获取帧间隔时间 | Number |
| **Get Time** | 获取运行总时间 | Number |
**示例:延迟 2 秒后执行**
```
[Event BeginPlay] ──→ [Delay] ──→ [Print]
└─ Duration: 2.0 └─ "2秒后执行"
```
### 数学节点 (Math)
| 节点 | 功能 |
|------|------|
| **Add / Subtract / Multiply / Divide** | 四则运算 |
| **Abs** | 绝对值 |
| **Clamp** | 限制在范围内 |
| **Lerp** | 线性插值 |
| **Min / Max** | 最小/最大值 |
| **Random Range** | 随机数 |
| **Sin / Cos / Tan** | 三角函数 |
### 调试节点 (Debug)
| 节点 | 功能 |
|------|------|
| **Print** | 输出到控制台 |
## 变量系统
变量用于在蓝图中存储和共享数据。
### 创建变量
1. 在变量面板点击 **+** 按钮
2. 输入变量名称
3. 选择变量类型
4. 设置默认值(可选)
### 使用变量
- **拖拽到画布** - 创建 Get 或 Set 节点
- **Get 节点** - 读取变量值
- **Set 节点** - 写入变量值
### 变量类型
| 类型 | 说明 | 默认值 |
|------|------|--------|
| Boolean | 布尔值 | false |
| Number | 数值 | 0 |
| String | 字符串 | "" |
| Entity | 实体引用 | null |
| Vector2 | 二维向量 | (0, 0) |
| Vector3 | 三维向量 | (0, 0, 0) |
### 变量节点错误状态
如果删除了一个变量,但画布上还有引用该变量的节点:
- 节点会显示 **红色边框****警告图标**
- 需要重新创建变量或删除这些节点
## 节点分组
可以将多个节点组织到一个可视化组框中,便于整理复杂蓝图。
### 创建组
1. 框选或 Ctrl+点击 选中多个节点(至少 2 个)
2. 右键点击选中的节点
3. 选择 **创建分组**
4. 组框会自动包裹所有选中的节点
### 组操作
| 操作 | 方式 |
|------|------|
| 移动组 | 拖拽组框头部,所有节点一起移动 |
| 取消分组 | 右键点击组框 → **取消分组** |
### 特性
- **动态大小**:组框会自动调整大小以包裹所有节点
- **独立移动**:可以单独移动组内的节点,组框会自动调整
- **仅编辑器**:组是纯视觉组织,不影响运行时逻辑
## 快捷键
| 快捷键 | 功能 |
|--------|------|
| `Ctrl + S` | 保存蓝图 |
| `Ctrl + Z` | 撤销 |
| `Ctrl + Shift + Z` | 重做 |
| `Ctrl + C` | 复制选中节点 |
| `Ctrl + X` | 剪切选中节点 |
| `Ctrl + V` | 粘贴节点 |
| `Delete` | 删除选中项 |
| `Ctrl + A` | 全选 |
## 保存与加载
### 保存蓝图
1. 点击工具栏 **保存** 按钮
2. 选择保存位置(**必须保存在 `assets/resources` 目录下**,否则 Cocos Creator 无法动态加载)
3. 文件扩展名为 `.blueprint.json`
> **重要提示**:蓝图文件必须放在 `resources` 目录下,游戏运行时才能通过 `cc.resources.load()` 加载。
### 加载蓝图
1. 点击工具栏 **打开** 按钮
2. 选择 `.blueprint.json` 文件
### 蓝图文件格式
蓝图保存为 JSON 格式,可与 `@esengine/blueprint` 运行时兼容:
```json
{
"version": 1,
"type": "blueprint",
"metadata": {
"name": "PlayerController",
"description": "玩家控制逻辑"
},
"variables": [],
"nodes": [],
"connections": []
}
```
## 实战示例
### 示例 1移动控制
实现每帧移动实体:
```
[Event Tick] ─Exec─→ [Get Self] ─Entity─→ [Get Component: Transform]
[Get Delta Time] ▼
│ [Set Property: x]
│ │
[Multiply] ◄──────────────┘
└─ Speed: 100
```
### 示例 2生命值系统
受伤后检查死亡:
```
[On Damage Event] ─→ [Get Component: Health] ─→ [Get Property: current]
[Subtract]
[Set Property: current]
┌─ True ─→ [Destroy Self]
[Branch]─┤
└─ False ─→ (继续)
[Less Or Equal]
current <= 0
```
### 示例 3延迟生成
每 2 秒生成一个敌人:
```
[Event BeginPlay] ─→ [Do N Times] ─Loop─→ [Delay: 2.0] ─→ [Create Entity: Enemy]
└─ N: 10
```
## 常见问题
### Q: 节点无法连接?
检查引脚类型是否匹配。执行引脚(白色)只能连接执行引脚,数据引脚需要类型兼容。
### Q: 蓝图不执行?
1. 确保实体添加了 `BlueprintComponent`
2. 确保场景添加了 `BlueprintSystem`
3. 检查 `autoStart` 是否为 `true`
### Q: 如何调试?
使用 **Print** 节点输出变量值到控制台。
## 下一步
- [ECS 节点参考](./nodes) - 完整节点列表
- [自定义节点](./custom-nodes) - 创建自定义节点
- [运行时集成](./vm) - 蓝图虚拟机 API
- [实际示例](./examples) - 更多游戏逻辑示例

View File

@@ -3,85 +3,127 @@ title: "实际示例"
description: "ECS 集成和最佳实践"
---
## 玩家控制蓝图
## 完整游戏集成示例
```typescript
// 定义输入处理节点
const InputMoveTemplate: BlueprintNodeTemplate = {
type: 'InputMove',
title: 'Get Movement Input',
category: 'input',
inputs: [],
outputs: [
{ name: 'direction', type: 'vector2', direction: 'output' }
],
isPure: true
};
import { Scene, Core, Component, ECSComponent } from '@esengine/ecs-framework';
import {
BlueprintSystem,
BlueprintComponent,
BlueprintExpose,
BlueprintProperty,
BlueprintMethod
} from '@esengine/blueprint';
@RegisterNode(InputMoveTemplate)
class InputMoveExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const input = context.scene.services.get(InputServiceToken);
const direction = {
x: input.getAxis('horizontal'),
y: input.getAxis('vertical')
};
return { outputs: { direction } };
// 1. 定义游戏组件
@ECSComponent('Player')
@BlueprintExpose({ displayName: '玩家', category: 'gameplay' })
export class PlayerComponent extends Component {
@BlueprintProperty({ displayName: '移动速度', type: 'float' })
moveSpeed: number = 5;
@BlueprintProperty({ displayName: '分数', type: 'int' })
score: number = 0;
@BlueprintMethod({ displayName: '增加分数' })
addScore(points: number): void {
this.score += points;
}
}
@ECSComponent('Health')
@BlueprintExpose({ displayName: '生命值', category: 'gameplay' })
export class HealthComponent extends Component {
@BlueprintProperty({ displayName: '当前生命值' })
current: number = 100;
@BlueprintProperty({ displayName: '最大生命值' })
max: number = 100;
@BlueprintMethod({ displayName: '治疗' })
heal(amount: number): void {
this.current = Math.min(this.current + amount, this.max);
}
@BlueprintMethod({ displayName: '受伤' })
takeDamage(amount: number): boolean {
this.current -= amount;
return this.current <= 0;
}
}
// 2. 初始化游戏
async function initGame() {
const scene = new Scene();
// 添加蓝图系统
scene.addSystem(new BlueprintSystem());
Core.setScene(scene);
// 3. 创建玩家
const player = scene.createEntity('Player');
player.addComponent(new PlayerComponent());
player.addComponent(new HealthComponent());
// 添加蓝图控制
const blueprint = new BlueprintComponent();
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
player.addComponent(blueprint);
}
```
## 状态切换逻辑
## 自定义节点示例
```typescript
// 在蓝图中实现状态机逻辑
const stateBlueprint = createEmptyBlueprint('PlayerState');
import type { Entity } from '@esengine/ecs-framework';
import {
BlueprintNodeTemplate,
BlueprintNode,
ExecutionContext,
ExecutionResult,
INodeExecutor,
RegisterNode
} from '@esengine/blueprint';
// 添加状态变量
stateBlueprint.variables.push({
name: 'currentState',
type: 'string',
defaultValue: 'idle',
scope: 'instance'
});
// 在 Tick 事件中检查状态转换
// ... 通过节点连接实现
```
## 伤害处理系统
```typescript
// 自定义伤害节点
const ApplyDamageTemplate: BlueprintNodeTemplate = {
type: 'ApplyDamage',
title: 'Apply Damage',
category: 'combat',
color: '#aa2222',
description: '对带有 Health 组件的实体造成伤害',
keywords: ['damage', 'hurt', 'attack'],
menuPath: ['Combat', 'Apply Damage'],
inputs: [
{ name: 'exec', type: 'exec', direction: 'input', isExec: true },
{ name: 'target', type: 'entity', direction: 'input' },
{ name: 'amount', type: 'number', direction: 'input', defaultValue: 10 }
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'target', type: 'entity', displayName: '目标' },
{ name: 'amount', type: 'float', displayName: '伤害量', defaultValue: 10 }
],
outputs: [
{ name: 'exec', type: 'exec', direction: 'output', isExec: true },
{ name: 'killed', type: 'boolean', direction: 'output' }
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'killed', type: 'bool', displayName: '已击杀' }
]
};
@RegisterNode(ApplyDamageTemplate)
class ApplyDamageExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const target = context.getInput<Entity>(node.id, 'target');
const amount = context.getInput<number>(node.id, 'amount');
const target = context.evaluateInput(node.id, 'target', context.entity) as Entity;
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
if (!target || target.isDestroyed) {
return { outputs: { killed: false }, nextExec: 'exec' };
}
const health = target.components.find(c =>
(c.constructor as any).__componentName__ === 'Health'
) as any;
const health = target.getComponent(HealthComponent);
if (health) {
health.current -= amount;
const killed = health.current <= 0;
return {
outputs: { killed },
nextExec: 'exec'
};
return { outputs: { killed }, nextExec: 'exec' };
}
return { outputs: { killed: false }, nextExec: 'exec' };
@@ -89,25 +131,6 @@ class ApplyDamageExecutor implements INodeExecutor {
}
```
## 技能冷却系统
```typescript
// 冷却检查节点
const CheckCooldownTemplate: BlueprintNodeTemplate = {
type: 'CheckCooldown',
title: 'Check Cooldown',
category: 'ability',
inputs: [
{ name: 'skillId', type: 'string', direction: 'input' }
],
outputs: [
{ name: 'ready', type: 'boolean', direction: 'output' },
{ name: 'remaining', type: 'number', direction: 'output' }
],
isPure: true
};
```
## 最佳实践
### 1. 使用片段复用逻辑
@@ -151,7 +174,8 @@ vm.maxStepsPerFrame = 1000;
```typescript
// 启用调试模式查看执行日志
vm.debug = true;
const blueprint = entity.getComponent(BlueprintComponent);
blueprint.debug = true;
// 使用 Print 节点输出中间值
// 在编辑器中设置断点

View File

@@ -1,114 +1,163 @@
---
title: "蓝图可视化脚本 (Blueprint)"
description: "完整的可视化脚本系统"
description: "与 ECS 框架深度集成的可视化脚本系统"
---
`@esengine/blueprint` 提供了一个功能完整的可视化脚本系统,支持节点式编程、事件驱动和蓝图组合
`@esengine/blueprint` 提供与 ECS 框架深度集成的可视化脚本系统,支持通过节点式编程控制实体行为
## 安装
## 编辑器下载
> **内测中**:蓝图编辑器目前处于内测阶段,需要激活码才能使用。
> 请加入 QQ 群 **481923584** 后私聊群主获取激活码。
Cocos Creator 蓝图编辑器插件:
**[下载 Cocos Node Editor v1.0.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.0.0)**
详细使用教程请参考 [编辑器使用指南](./editor-guide)。
## 安装运行时
```bash
npm install @esengine/blueprint
```
## 核心特性
- **ECS 深度集成** - 内置 Entity、Component 操作节点
- **组件自动节点生成** - 使用装饰器标记组件,自动生成 Get/Set/Call 节点
- **运行时蓝图执行** - 高效的虚拟机执行蓝图逻辑
## 快速开始
### 1. 添加蓝图系统
```typescript
import { Scene, Core } from '@esengine/ecs-framework';
import { BlueprintSystem } from '@esengine/blueprint';
// 创建场景并添加蓝图系统
const scene = new Scene();
scene.addSystem(new BlueprintSystem());
// 设置场景
Core.setScene(scene);
```
### 2. 为实体添加蓝图
```typescript
import { BlueprintComponent } from '@esengine/blueprint';
// 创建实体
const player = scene.createEntity('Player');
// 添加蓝图组件
const blueprint = new BlueprintComponent();
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
blueprint.autoStart = true;
player.addComponent(blueprint);
```
### 3. 标记组件(自动生成蓝图节点)
```typescript
import {
createBlueprintSystem,
createBlueprintComponentData,
NodeRegistry,
RegisterNode
BlueprintExpose,
BlueprintProperty,
BlueprintMethod
} from '@esengine/blueprint';
import { Component, ECSComponent } from '@esengine/ecs-framework';
// 创建蓝图系统
const blueprintSystem = createBlueprintSystem(scene);
@ECSComponent('Health')
@BlueprintExpose({ displayName: '生命值', category: 'gameplay' })
export class HealthComponent extends Component {
@BlueprintProperty({ displayName: '当前生命值', type: 'float' })
current: number = 100;
// 加载蓝图资产
const blueprint = await loadBlueprintAsset('player.bp');
@BlueprintProperty({ displayName: '最大生命值', type: 'float' })
max: number = 100;
// 创建蓝图组件数据
const componentData = createBlueprintComponentData();
componentData.blueprintAsset = blueprint;
@BlueprintMethod({
displayName: '治疗',
params: [{ name: 'amount', type: 'float' }]
})
heal(amount: number): void {
this.current = Math.min(this.current + amount, this.max);
}
// 在游戏循环中更新
function gameLoop(dt: number) {
blueprintSystem.process(entities, dt);
@BlueprintMethod({ displayName: '受伤' })
takeDamage(amount: number): boolean {
this.current -= amount;
return this.current <= 0;
}
}
```
## 核心概念
标记后,蓝图编辑器中会自动出现以下节点:
- **Get Health** - 获取 Health 组件
- **Get 当前生命值** - 获取 current 属性
- **Set 当前生命值** - 设置 current 属性
- **治疗** - 调用 heal 方法
- **受伤** - 调用 takeDamage 方法
### 蓝图资产结
## ECS 集成架
蓝图保存为 `.bp` 文件,包含以下结构:
```typescript
interface BlueprintAsset {
version: number; // 格式版本
type: 'blueprint'; // 资产类型
metadata: BlueprintMetadata; // 元数据
variables: BlueprintVariable[]; // 变量定义
nodes: BlueprintNode[]; // 节点实例
connections: BlueprintConnection[]; // 连接
}
```
┌─────────────────────────────────────────────────────────────┐
│ Core.update() │
│ ↓ │
Scene.updateSystems() │
↓ │
│ ┌───────────────────────────────────────────────────────┐ │
BlueprintSystem │ │
│ │
Matcher.all(BlueprintComponent) │ │
│ │ ↓ │ │
│ │ process(entities) → blueprint.tick() for each entity │ │
│ │ ↓ │ │
│ │ BlueprintVM.tick(dt) │ │
│ │ ↓ │ │
│ │ Execute Event/ECS/Flow Nodes │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 节点类型
节点按功能分为以下类别:
## 节点类型
| 类别 | 说明 | 颜色 |
|------|------|------|
| `event` | 事件节点(入口点 | 红色 |
| `flow` | 流程控制 | 色 |
| `entity` | 实体操作 | 色 |
| `component` | 组件访问 | 色 |
| `event` | 事件节点(BeginPlay, Tick, EndPlay | 红色 |
| `entity` | ECS 实体操作 | 色 |
| `component` | ECS 组件访问 | 色 |
| `flow` | 流程控制Branch, Sequence, Loop | 色 |
| `math` | 数学运算 | 绿色 |
| `logic` | 逻辑运算 | 色 |
| `variable` | 变量访问 | 色 |
| `time` | 时间工具 | 青色 |
| `debug` | 调试工具 | 灰色 |
| `time` | 时间工具Delay, GetDeltaTime | 色 |
| `debug` | 调试工具Print | 色 |
### 引脚类型
## 蓝图资产结构
节点通过引脚连接
蓝图保存为 `.bp` 文件
```typescript
interface BlueprintPinDefinition {
name: string; // 引脚名称
type: PinDataType; // 数据类型
direction: 'input' | 'output';
isExec?: boolean; // 是否是执行引脚
defaultValue?: unknown;
interface BlueprintAsset {
version: number;
type: 'blueprint';
metadata: {
name: string;
description?: string;
};
variables: BlueprintVariable[];
nodes: BlueprintNode[];
connections: BlueprintConnection[];
}
// 支持的数据类型
type PinDataType =
| 'exec' // 执行流
| 'boolean' // 布尔值
| 'number' // 数字
| 'string' // 字符串
| 'vector2' // 2D 向量
| 'vector3' // 3D 向量
| 'entity' // 实体引用
| 'component' // 组件引用
| 'any'; // 任意类型
```
### 变量作用域
```typescript
type VariableScope =
| 'local' // 每次执行独立
| 'instance' // 每个实体独立
| 'global'; // 全局共享
```
## 文档导航
- [虚拟机 API](./vm) - BlueprintVM 执行和上下文
- [自定义节点](./custom-nodes) - 创建自定义节点
- [内置节点](./nodes) - 内置节点参考
- [蓝图组合](./composition) - 片段和组合器
- [实际示例](./examples) - ECS 集成和最佳实践
- [编辑器使用指南](./editor-guide) - Cocos Creator 蓝图编辑器教程
- [虚拟机 API](./vm) - BlueprintVM 与 ECS 集成
- [ECS 节点参考](./nodes) - 内置 ECS 操作节点
- [自定义节点](./custom-nodes) - 创建自定义 ECS 节点
- [蓝图组合](./composition) - 片段复用
- [实际示例](./examples) - ECS 游戏逻辑示例

View File

@@ -1,107 +1,192 @@
---
title: "内置节点"
description: "蓝图内置节点参考"
title: "ECS 节点参考"
description: "蓝图内置 ECS 操作节点"
---
## 事件节点
生命周期事件,作为蓝图执行的入口点:
| 节点 | 说明 |
|------|------|
| `EventBeginPlay` | 蓝图启动时触发 |
| `EventTick` | 每帧触发 |
| `EventTick` | 每帧触发,接收 deltaTime |
| `EventEndPlay` | 蓝图停止时触发 |
| `EventCollision` | 碰撞时触发 |
| `EventInput` | 输入事件触发 |
| `EventTimer` | 定时器触发 |
| `EventMessage` | 自定义消息触发 |
## 流程控制节点
## 实体节点 (Entity)
操作 ECS 实体:
| 节点 | 说明 | 类型 |
|------|------|------|
| `Get Self` | 获取拥有此蓝图的实体 | 纯节点 |
| `Create Entity` | 在场景中创建新实体 | 执行节点 |
| `Destroy Entity` | 销毁指定实体 | 执行节点 |
| `Destroy Self` | 销毁自身实体 | 执行节点 |
| `Is Valid` | 检查实体是否有效 | 纯节点 |
| `Get Entity Name` | 获取实体名称 | 纯节点 |
| `Set Entity Name` | 设置实体名称 | 执行节点 |
| `Get Entity Tag` | 获取实体标签 | 纯节点 |
| `Set Entity Tag` | 设置实体标签 | 执行节点 |
| `Set Active` | 设置实体激活状态 | 执行节点 |
| `Is Active` | 检查实体是否激活 | 纯节点 |
| `Find Entity By Name` | 按名称查找实体 | 纯节点 |
| `Find Entities By Tag` | 按标签查找所有实体 | 纯节点 |
| `Get Entity ID` | 获取实体唯一 ID | 纯节点 |
| `Find Entity By ID` | 按 ID 查找实体 | 纯节点 |
## 组件节点 (Component)
操作 ECS 组件:
| 节点 | 说明 | 类型 |
|------|------|------|
| `Has Component` | 检查实体是否有指定组件 | 纯节点 |
| `Get Component` | 获取实体的组件 | 纯节点 |
| `Get All Components` | 获取实体所有组件 | 纯节点 |
| `Remove Component` | 移除组件 | 执行节点 |
| `Get Component Property` | 获取组件属性值 | 纯节点 |
| `Set Component Property` | 设置组件属性值 | 执行节点 |
| `Get Component Type` | 获取组件类型名称 | 纯节点 |
| `Get Owner Entity` | 从组件获取所属实体 | 纯节点 |
## 流程控制节点 (Flow)
控制执行流程:
| 节点 | 说明 |
|------|------|
| `Branch` | 条件分支 (if/else) |
| `Sequence` | 顺序执行多个输出 |
| `ForLoop` | 循环执行 |
| `WhileLoop` | 条件循环 |
| `DoOnce` | 只执行一次 |
| `FlipFlop` | 交替执行两个分支 |
| `For Loop` | 循环执行 |
| `For Each` | 遍历数组 |
| `While Loop` | 条件循环 |
| `Do Once` | 只执行一次 |
| `Flip Flop` | 交替执行两个分支 |
| `Gate` | 可开关的执行门 |
## 时间节点
## 时间节点 (Time)
| 节点 | 说明 | 类型 |
|------|------|------|
| `Delay` | 延迟执行 | 执行节点 |
| `Get Delta Time` | 获取帧间隔时间 | 纯节点 |
| `Get Time` | 获取运行总时间 | 纯节点 |
## 数学节点 (Math)
基础运算:
| 节点 | 说明 |
|------|------|
| `Delay` | 延迟执行 |
| `GetDeltaTime` | 获取帧间隔 |
| `GetTime` | 获取运行时间 |
| `SetTimer` | 设置定时器 |
| `ClearTimer` | 清除定时器 |
## 数学节点
| 节点 | 说明 |
|------|------|
| `Add` | 加法 |
| `Subtract` | 减法 |
| `Multiply` | 乘法 |
| `Divide` | 除法 |
| `Add` / `Subtract` / `Multiply` / `Divide` | 四则运算 |
| `Modulo` | 取模运算 (%) |
| `Negate` | 取负 |
| `Abs` | 绝对值 |
| `Clamp` | 限制范围 |
| `Lerp` | 线性插值 |
| `Sign` | 符号 (+1, 0, -1) |
| `Min` / `Max` | 最小/最大值 |
| `Sin` / `Cos` | 三角函数 |
| `Clamp` | 限制在范围内 |
| `Wrap` | 循环限制在范围内 |
幂与根:
| 节点 | 说明 |
|------|------|
| `Power` | 幂运算 (A^B) |
| `Sqrt` | 平方根 |
| `Power` | 幂运算 |
## 逻辑节点
取整:
| 节点 | 说明 |
|------|------|
| `And` | 逻辑与 |
| `Or` | 逻辑或 |
| `Not` | 逻辑非 |
| `Equal` | 相等比较 |
| `NotEqual` | 不等比较 |
| `Greater` | 大于比较 |
| `Less` | 小于比较 |
| `Floor` | 向下取整 |
| `Ceil` | 向上取整 |
| `Round` | 四舍五入 |
## 向量节点
三角函数:
| 节点 | 说明 |
|------|------|
| `MakeVector2` | 创建 2D 向量 |
| `BreakVector2` | 分解 2D 向量 |
| `VectorAdd` | 向量加法 |
| `VectorSubtract` | 向量减法 |
| `VectorMultiply` | 向量乘法 |
| `VectorLength` | 向量长度 |
| `VectorNormalize` | 向量归一化 |
| `VectorDistance` | 向量距离 |
| `Sin` / `Cos` / `Tan` | 正弦/余弦/正切 |
| `Asin` / `Acos` / `Atan` | 反三角函数 |
| `Atan2` | 两参数反正切 |
| `DegToRad` / `RadToDeg` | 角度与弧度转换 |
## 实体节点
插值:
| 节点 | 说明 |
|------|------|
| `GetSelf` | 获取当前实体 |
| `GetComponent` | 获取组件 |
| `HasComponent` | 检查组件 |
| `AddComponent` | 添加组件 |
| `RemoveComponent` | 移除组件 |
| `SpawnEntity` | 创建实体 |
| `DestroyEntity` | 销毁实体 |
| `Lerp` | 线性插值 |
| `InverseLerp` | 反向线性插值 |
## 变量节点
随机数:
| 节点 | 说明 |
|------|------|
| `GetVariable` | 获取变量值 |
| `SetVariable` | 设置变量值 |
| `Random Range` | 范围内随机浮点数 |
| `Random Int` | 范围内随机整数 |
## 调试节点
## 逻辑节点 (Logic)
比较运算:
| 节点 | 说明 |
|------|------|
| `Print` | 打印到控制台 |
| `DrawDebugLine` | 绘制调试线 |
| `DrawDebugPoint` | 绘制调试点 |
| `Breakpoint` | 调试断点 |
| `Equal` | 等于 (==) |
| `Not Equal` | 不等于 (!=) |
| `Greater Than` | 大于 (>) |
| `Greater Or Equal` | 大于等于 (>=) |
| `Less Than` | 小于 (<) |
| `Less Or Equal` | 小于等于 (<=) |
| `In Range` | 检查值是否在范围内 |
逻辑运算:
| 节点 | 说明 |
|------|------|
| `AND` | 逻辑与 |
| `OR` | 逻辑或 |
| `NOT` | 逻辑非 |
| `XOR` | 异或 |
| `NAND` | 与非 |
工具节点:
| 节点 | 说明 |
|------|------|
| `Is Null` | 检查值是否为空 |
| `Select` | 根据条件选择 A 或 B (三元运算) |
## 调试节点 (Debug)
| 节点 | 说明 |
|------|------|
| `Print` | 输出到控制台 |
## 自动生成的组件节点
使用 `@BlueprintExpose` 装饰器标记的组件会自动生成节点:
```typescript
@ECSComponent('Transform')
@BlueprintExpose({ displayName: '变换', category: 'core' })
export class TransformComponent extends Component {
@BlueprintProperty({ displayName: 'X 坐标' })
x: number = 0;
@BlueprintProperty({ displayName: 'Y 坐标' })
y: number = 0;
@BlueprintMethod({ displayName: '移动' })
translate(dx: number, dy: number): void {
this.x += dx;
this.y += dy;
}
}
```
生成的节点:
- **Get Transform** - 获取 Transform 组件
- **Get X 坐标** / **Set X 坐标** - 访问 x 属性
- **Get Y 坐标** / **Set Y 坐标** - 访问 y 属性
- **移动** - 调用 translate 方法

View File

@@ -45,7 +45,7 @@ interface ExecutionContext {
time: number; // 总运行时间
// 获取输入值
getInput<T>(nodeId: string, pinName: string): T;
evaluateInput(nodeId: string, pinName: string, defaultValue: unknown): unknown;
// 设置输出值
setOutput(nodeId: string, pinName: string, value: unknown): void;
@@ -70,35 +70,33 @@ interface ExecutionResult {
## 与 ECS 集成
### 使用蓝图系统
### 使用内置蓝图系统
```typescript
import { createBlueprintSystem } from '@esengine/blueprint';
import { Scene, Core } from '@esengine/ecs-framework';
import { BlueprintSystem, BlueprintComponent } from '@esengine/blueprint';
class GameScene {
private blueprintSystem: BlueprintSystem;
// 添加蓝图系统到场景
const scene = new Scene();
scene.addSystem(new BlueprintSystem());
Core.setScene(scene);
initialize() {
this.blueprintSystem = createBlueprintSystem(this.scene);
}
update(dt: number) {
// 处理所有带蓝图组件的实体
this.blueprintSystem.process(this.entities, dt);
}
}
// 为实体添加蓝图
const entity = scene.createEntity('Player');
const blueprint = new BlueprintComponent();
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
entity.addComponent(blueprint);
```
### 触发蓝图事件
```typescript
import { triggerBlueprintEvent, triggerCustomBlueprintEvent } from '@esengine/blueprint';
// 触发内置事件
triggerBlueprintEvent(entity, 'Collision', { other: otherEntity });
// 触发自定义事件
triggerCustomBlueprintEvent(entity, 'OnPickup', { item: itemEntity });
// 从实体获取蓝图组件并触发事件
const blueprint = entity.getComponent(BlueprintComponent);
if (blueprint?.vm) {
blueprint.vm.triggerEvent('EventCollision', { other: otherEntity });
blueprint.vm.triggerCustomEvent('OnPickup', { item: itemEntity });
}
```
## 序列化

View File

@@ -0,0 +1,441 @@
---
title: "分布式房间"
description: "使用 DistributedRoomManager 实现多服务器房间管理"
---
## 概述
分布式房间支持允许多个服务器实例共享房间注册表,实现跨服务器玩家路由和故障转移。
```
┌─────────────────────────────────────────────────────────┐
│ Server A Server B Server C │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Room 1 │ │ Room 3 │ │ Room 5 │ │
│ │ Room 2 │ │ Room 4 │ │ Room 6 │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ └─────────────────┼─────────────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ IDistributedAdapter │ │
│ │ (Redis / Memory) │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
## 快速开始
### 单机模式(测试用)
```typescript
import {
DistributedRoomManager,
MemoryAdapter,
Room
} from '@esengine/server';
// 定义房间类型
class GameRoom extends Room {
maxPlayers = 4;
}
// 创建适配器和管理器
const adapter = new MemoryAdapter();
const manager = new DistributedRoomManager(adapter, {
serverId: 'server-1',
serverAddress: 'localhost',
serverPort: 3000
}, (conn, type, data) => conn.send(JSON.stringify({ type, data })));
// 注册房间类型
manager.define('game', GameRoom);
// 启动管理器
await manager.start();
// 分布式加入/创建房间
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
if ('redirect' in result) {
// 玩家应连接到其他服务器
console.log(`重定向到: ${result.redirect}`);
} else {
// 玩家加入本地房间
const { room, player } = result;
}
// 优雅关闭
await manager.stop(true);
```
### 多服务器模式(生产用)
```typescript
import Redis from 'ioredis';
import { DistributedRoomManager, RedisAdapter } from '@esengine/server';
const adapter = new RedisAdapter({
factory: () => new Redis({
host: 'redis.example.com',
port: 6379
}),
prefix: 'game:',
serverTtl: 30,
snapshotTtl: 86400
});
const manager = new DistributedRoomManager(adapter, {
serverId: process.env.SERVER_ID,
serverAddress: process.env.PUBLIC_IP,
serverPort: 3000,
heartbeatInterval: 5000,
snapshotInterval: 30000,
enableFailover: true,
capacity: 100
}, sendFn);
```
## DistributedRoomManager
### 配置选项
| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `serverId` | `string` | 必填 | 服务器唯一标识 |
| `serverAddress` | `string` | 必填 | 客户端连接的公开地址 |
| `serverPort` | `number` | 必填 | 服务器端口 |
| `heartbeatInterval` | `number` | `5000` | 心跳间隔(毫秒) |
| `snapshotInterval` | `number` | `30000` | 状态快照间隔0 禁用 |
| `migrationTimeout` | `number` | `10000` | 房间迁移超时 |
| `enableFailover` | `boolean` | `true` | 启用自动故障转移 |
| `capacity` | `number` | `100` | 本服务器最大房间数 |
### 生命周期方法
#### start()
启动分布式房间管理器。连接适配器、注册服务器、启动心跳。
```typescript
await manager.start();
```
#### stop(graceful?)
停止管理器。如果 `graceful=true`,将服务器标记为 draining 并保存所有房间快照。
```typescript
await manager.stop(true);
```
### 路由方法
#### joinOrCreateDistributed()
分布式感知的加入或创建房间。返回本地房间的 `{ room, player }` 或远程房间的 `{ redirect: string }`
```typescript
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
if ('redirect' in result) {
// 客户端应重定向到其他服务器
res.json({ redirect: result.redirect });
} else {
// 玩家加入了本地房间
const { room, player } = result;
}
```
#### route()
将玩家路由到合适的房间/服务器。
```typescript
const result = await manager.route({
roomType: 'game',
playerId: 'p1'
});
switch (result.type) {
case 'local': // 房间在本服务器
break;
case 'redirect': // 房间在其他服务器
// result.serverAddress 包含目标服务器地址
break;
case 'create': // 没有可用房间,需要创建
break;
case 'unavailable': // 无法找到或创建房间
// result.reason 包含错误信息
break;
}
```
### 状态管理
#### saveSnapshot()
手动保存房间状态快照。
```typescript
await manager.saveSnapshot(roomId);
```
#### restoreFromSnapshot()
从保存的快照恢复房间。
```typescript
const success = await manager.restoreFromSnapshot(roomId);
```
### 查询方法
#### getServers()
获取所有在线服务器。
```typescript
const servers = await manager.getServers();
```
#### queryDistributedRooms()
查询所有服务器上的房间。
```typescript
const rooms = await manager.queryDistributedRooms({
roomType: 'game',
hasSpace: true,
notLocked: true
});
```
## IDistributedAdapter
分布式后端的接口。实现此接口以支持 Redis、消息队列等。
### 内置适配器
#### MemoryAdapter
用于测试和单机模式的内存实现。
```typescript
const adapter = new MemoryAdapter({
serverTtl: 15000, // 无心跳后服务器离线时间(毫秒)
enableTtlCheck: true, // 启用自动 TTL 检查
ttlCheckInterval: 5000 // TTL 检查间隔(毫秒)
});
```
#### RedisAdapter
用于生产环境多服务器部署的 Redis 实现。
```typescript
import Redis from 'ioredis';
import { RedisAdapter } from '@esengine/server';
const adapter = new RedisAdapter({
factory: () => new Redis('redis://localhost:6379'),
prefix: 'game:', // 键前缀(默认: 'dist:'
serverTtl: 30, // 服务器 TTL默认: 30
roomTtl: 0, // 房间 TTL0 = 永不过期(默认: 0
snapshotTtl: 86400, // 快照 TTL默认: 24 小时)
channel: 'game:events' // Pub/Sub 频道(默认: 'distributed:events'
});
```
**RedisAdapter 配置:**
| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `factory` | `() => RedisClient` | 必填 | Redis 客户端工厂(惰性连接) |
| `prefix` | `string` | `'dist:'` | 所有 Redis 键的前缀 |
| `serverTtl` | `number` | `30` | 服务器 TTL |
| `roomTtl` | `number` | `0` | 房间 TTL0 = 不过期 |
| `snapshotTtl` | `number` | `86400` | 快照 TTL |
| `channel` | `string` | `'distributed:events'` | Pub/Sub 频道名 |
**功能特性:**
- 带自动心跳 TTL 的服务器注册
- 跨服务器查找的房间注册
- 可配置 TTL 的状态快照
- 跨服务器事件的 Pub/Sub
- 使用 Redis SET NX 的分布式锁
### 自定义适配器
```typescript
import type { IDistributedAdapter } from '@esengine/server';
class MyAdapter implements IDistributedAdapter {
// 生命周期
async connect(): Promise<void> { }
async disconnect(): Promise<void> { }
isConnected(): boolean { return true; }
// 服务器注册
async registerServer(server: ServerRegistration): Promise<void> { }
async unregisterServer(serverId: string): Promise<void> { }
async heartbeat(serverId: string): Promise<void> { }
async getServers(): Promise<ServerRegistration[]> { return []; }
// 房间注册
async registerRoom(room: RoomRegistration): Promise<void> { }
async unregisterRoom(roomId: string): Promise<void> { }
async queryRooms(query: RoomQuery): Promise<RoomRegistration[]> { return []; }
async findAvailableRoom(roomType: string): Promise<RoomRegistration | null> { return null; }
// 状态快照
async saveSnapshot(snapshot: RoomSnapshot): Promise<void> { }
async loadSnapshot(roomId: string): Promise<RoomSnapshot | null> { return null; }
// 发布/订阅
async publish(event: DistributedEvent): Promise<void> { }
async subscribe(pattern: string, handler: Function): Promise<() => void> { return () => {}; }
// 分布式锁
async acquireLock(key: string, ttlMs: number): Promise<boolean> { return true; }
async releaseLock(key: string): Promise<void> { }
}
```
## 玩家路由流程
```
客户端 服务器 A 服务器 B
│ │ │
│─── joinOrCreate ────────►│ │
│ │ │
│ │── findAvailableRoom() ───►│
│ │◄──── 服务器 B 上有房间 ────│
│ │ │
│◄─── redirect: B:3001 ────│ │
│ │ │
│───────────────── 连接到服务器 B ────────────────────►│
│ │ │
│◄─────────────────────────────── 已加入 ─────────────│
```
## 事件类型
分布式系统发布以下事件:
| 事件 | 描述 |
|------|------|
| `server:online` | 服务器上线 |
| `server:offline` | 服务器离线 |
| `server:draining` | 服务器正在排空 |
| `room:created` | 房间已创建 |
| `room:disposed` | 房间已销毁 |
| `room:updated` | 房间信息已更新 |
| `room:message` | 跨服务器房间消息 |
| `room:migrated` | 房间已迁移到其他服务器 |
| `player:joined` | 玩家加入房间 |
| `player:left` | 玩家离开房间 |
## 最佳实践
1. **使用唯一服务器 ID** - 使用主机名、容器 ID 或 UUID
2. **配置合适的心跳** - 在新鲜度和网络开销之间平衡
3. **为有状态房间启用快照** - 确保房间状态在服务器重启后存活
4. **优雅处理重定向** - 客户端应重新连接到目标服务器
```typescript
// 客户端处理重定向
if (response.redirect) {
await client.disconnect();
await client.connect(response.redirect);
await client.joinRoom(roomId);
}
```
5. **使用分布式锁** - 防止 joinOrCreate 中的竞态条件
## 使用 createServer 集成
最简单的使用方式是通过 `createServer` 的 `distributed` 配置:
```typescript
import { createServer } from '@esengine/server';
import { RedisAdapter, Room } from '@esengine/server';
import Redis from 'ioredis';
class GameRoom extends Room {
maxPlayers = 4;
}
const server = await createServer({
port: 3000,
distributed: {
enabled: true,
adapter: new RedisAdapter({ factory: () => new Redis() }),
serverId: 'server-1',
serverAddress: 'ws://192.168.1.100',
serverPort: 3000,
enableFailover: true,
capacity: 100
}
});
server.define('game', GameRoom);
await server.start();
```
当客户端调用 `JoinRoom` API 时,服务器会自动:
1. 查找可用房间(本地或远程)
2. 如果房间在其他服务器,发送 `$redirect` 消息给客户端
3. 客户端收到重定向消息后连接到目标服务器
## 负载均衡
使用 `LoadBalancedRouter` 进行服务器选择:
```typescript
import { LoadBalancedRouter, createLoadBalancedRouter } from '@esengine/server';
// 使用工厂函数
const router = createLoadBalancedRouter('least-players');
// 或直接创建
const router = new LoadBalancedRouter({
strategy: 'least-rooms', // 选择房间数最少的服务器
preferLocal: true // 优先选择本地服务器
});
// 可用策略
// - 'round-robin': 轮询
// - 'least-rooms': 最少房间数
// - 'least-players': 最少玩家数
// - 'random': 随机选择
// - 'weighted': 权重(基于容量使用率)
```
## 故障转移
当服务器离线时,启用 `enableFailover` 后系统会自动:
1. 检测到服务器离线(通过心跳超时)
2. 查询该服务器上的所有房间
3. 使用分布式锁防止多服务器同时恢复
4. 从快照恢复房间状态
5. 发布 `room:migrated` 事件通知其他服务器
```typescript
// 确保定期保存快照
const manager = new DistributedRoomManager(adapter, {
serverId: 'server-1',
serverAddress: 'localhost',
serverPort: 3000,
snapshotInterval: 30000, // 每 30 秒保存快照
enableFailover: true // 启用故障转移
}, sendFn);
```
## 后续版本
- Redis Cluster 支持
- 更多负载均衡策略(地理位置、延迟感知)

View File

@@ -147,6 +147,7 @@ service.on('chat', (data) => {
- [客户端使用](/modules/network/client/) - NetworkPlugin、组件和系统
- [服务器端](/modules/network/server/) - GameServer 和 Room 管理
- [分布式房间](/modules/network/distributed/) - 多服务器房间管理和玩家路由
- [状态同步](/modules/network/sync/) - 插值和快照缓冲
- [客户端预测](/modules/network/prediction/) - 输入预测和服务器校正
- [兴趣区域 (AOI)](/modules/network/aoi/) - 视野过滤和带宽优化

View File

@@ -280,6 +280,122 @@ class GameRoom extends Room {
}
```
## Schema 验证
使用内置的 Schema 验证系统进行运行时类型验证:
### 基础用法
```typescript
import { s, defineApiWithSchema } from '@esengine/server'
// 定义 Schema
const MoveSchema = s.object({
x: s.number(),
y: s.number(),
speed: s.number().optional()
})
// 类型自动推断
type Move = s.infer<typeof MoveSchema> // { x: number; y: number; speed?: number }
// 使用 Schema 定义 API自动验证
export default defineApiWithSchema(MoveSchema, {
handler(req, ctx) {
// req 已验证,类型安全
console.log(req.x, req.y)
}
})
```
### 验证器类型
| 类型 | 示例 | 描述 |
|------|------|------|
| `s.string()` | `s.string().min(1).max(50)` | 字符串,支持长度限制 |
| `s.number()` | `s.number().min(0).int()` | 数字,支持范围和整数限制 |
| `s.boolean()` | `s.boolean()` | 布尔值 |
| `s.literal()` | `s.literal('admin')` | 字面量类型 |
| `s.object()` | `s.object({ name: s.string() })` | 对象 |
| `s.array()` | `s.array(s.number())` | 数组 |
| `s.enum()` | `s.enum(['a', 'b'] as const)` | 枚举 |
| `s.union()` | `s.union([s.string(), s.number()])` | 联合类型 |
| `s.record()` | `s.record(s.any())` | 记录类型 |
### 修饰符
```typescript
// 可选字段
s.string().optional()
// 默认值
s.number().default(0)
// 可为 null
s.string().nullable()
// 字符串验证
s.string().min(1).max(100).email().url().regex(/^[a-z]+$/)
// 数字验证
s.number().min(0).max(100).int().positive()
// 数组验证
s.array(s.string()).min(1).max(10).nonempty()
// 对象验证
s.object({ ... }).strict() // 不允许额外字段
s.object({ ... }).partial() // 所有字段可选
s.object({ ... }).pick('name', 'age') // 选择字段
s.object({ ... }).omit('password') // 排除字段
```
### 消息验证
```typescript
import { s, defineMsgWithSchema } from '@esengine/server'
const InputSchema = s.object({
keys: s.array(s.string()),
timestamp: s.number()
})
export default defineMsgWithSchema(InputSchema, {
handler(msg, ctx) {
// msg 已验证
console.log(msg.keys, msg.timestamp)
}
})
```
### 手动验证
```typescript
import { s, parse, safeParse, createGuard } from '@esengine/server'
const UserSchema = s.object({
name: s.string(),
age: s.number().int().min(0)
})
// 抛出错误
const user = parse(UserSchema, data)
// 返回结果对象
const result = safeParse(UserSchema, data)
if (result.success) {
console.log(result.data)
} else {
console.error(result.error)
}
// 类型守卫
const isUser = createGuard(UserSchema)
if (isUser(data)) {
// data 是 User 类型
}
```
## 协议定义
`src/shared/protocol.ts` 中定义客户端和服务端共享的类型:

View File

@@ -13,6 +13,7 @@
"packages/network-ext/*",
"packages/editor/*",
"packages/editor/plugins/*",
"packages/devtools/*",
"packages/rust/*",
"packages/tools/*"
],

View File

@@ -0,0 +1,75 @@
# @esengine/node-editor
## 1.4.0
### Minor Changes
- [#438](https://github.com/esengine/esengine/pull/438) [`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b) Thanks [@esengine](https://github.com/esengine)! - feat(node-editor): add visual group box for organizing nodes
- Add NodeGroup model with dynamic bounds calculation based on node pin counts
- Add GroupNodeComponent for rendering group boxes behind nodes
- Groups automatically resize to wrap contained nodes
- Dragging group header moves all nodes inside together
- Support group serialization/deserialization
- Export `estimateNodeHeight` and `NodeBounds` for accurate size calculation
feat(blueprint): add comprehensive math and logic nodes
Math nodes:
- Modulo, Abs, Min, Max, Power, Sqrt
- Floor, Ceil, Round, Sign, Negate
- Sin, Cos, Tan, Asin, Acos, Atan, Atan2
- DegToRad, RadToDeg, Lerp, InverseLerp
- Clamp, Wrap, RandomRange, RandomInt
Logic nodes:
- Equal, NotEqual, GreaterThan, GreaterThanOrEqual
- LessThan, LessThanOrEqual, InRange
- AND, OR, NOT, XOR, NAND
- IsNull, Select (ternary)
## 1.3.0
### Minor Changes
- [`3bfb8a1`](https://github.com/esengine/esengine/commit/3bfb8a1c9baba18373717910d29f266a71c1f63e) Thanks [@esengine](https://github.com/esengine)! - feat(node-editor): add box selection and variable node error states
- Add box selection: drag on empty canvas to select multiple nodes
- Support Ctrl+drag for additive selection (add to existing selection)
- Add error state styling for invalid variable references (red border, warning icon)
- Support dynamic node title via `data.displayTitle`
- Support hiding inputs via `data.hiddenInputs` array
## 1.2.2
### Patch Changes
- [#435](https://github.com/esengine/esengine/pull/435) [`c2acd14`](https://github.com/esengine/esengine/commit/c2acd14fce83af6cd116b3f2e40607229ccc3d6e) Thanks [@esengine](https://github.com/esengine)! - fix(node-editor): 修复节点收缩后连线不显示的问题
- 节点收缩时,连线会连接到节点头部(输入引脚在左侧,输出引脚在右侧)
- 展开后连线会自动恢复到正确位置
## 1.2.1
### Patch Changes
- [#433](https://github.com/esengine/esengine/pull/433) [`2e84942`](https://github.com/esengine/esengine/commit/2e84942ea14c5326620398add05840fa8bea16f8) Thanks [@esengine](https://github.com/esengine)! - fix(node-editor): 修复节点收缩后连线不显示的问题
- 节点收缩时,连线会连接到节点头部(输入引脚在左侧,输出引脚在右侧)
- 展开后连线会自动恢复到正确位置
## 1.2.0
### Minor Changes
- [#430](https://github.com/esengine/esengine/pull/430) [`caf3be7`](https://github.com/esengine/esengine/commit/caf3be72cdcc730492c63abe5f1715893f3579ac) Thanks [@esengine](https://github.com/esengine)! - feat(node-editor): 添加 Shadow DOM 样式注入支持 | Add Shadow DOM style injection support
**@esengine/node-editor**
- 新增 `nodeEditorCssText` 导出,包含所有编辑器样式的 CSS 文本 | Added `nodeEditorCssText` export containing all editor styles as CSS text
- 新增 `injectNodeEditorStyles(root)` 函数,支持将样式注入到 Shadow DOM | Added `injectNodeEditorStyles(root)` function for injecting styles into Shadow DOM
- 支持在 Cocos Creator 等使用 Shadow DOM 的环境中使用 | Support usage in Shadow DOM environments like Cocos Creator
## 1.1.0
### Minor Changes
- [#426](https://github.com/esengine/esengine/pull/426) [`6970394`](https://github.com/esengine/esengine/commit/6970394717ab8f743b0a41e248e3404a3b6fc7dc) Thanks [@esengine](https://github.com/esengine)! - feat: 独立发布节点编辑器 | Standalone node editor release
- 移动到 packages/devtools 目录 | Move to packages/devtools directory
- 清理依赖,使包可独立使用 | Clean dependencies for standalone use
- 可用于 Cocos Creator / LayaAir 插件开发 | Available for Cocos/Laya plugin development

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/node-editor",
"version": "1.0.0",
"version": "1.4.0",
"description": "Universal node-based visual editor for blueprint, shader graph, and state machine",
"main": "dist/index.js",
"module": "dist/index.js",
@@ -9,7 +9,8 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./styles": {
"import": "./dist/styles/index.css"
@@ -30,17 +31,18 @@
"blueprint",
"shader-graph",
"state-machine",
"ecs",
"game-engine"
"react"
],
"author": "yhh",
"author": "ESEngine Team",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0"
},
"devDependencies": {
"react": "^18.3.1",
"zustand": "^5.0.8",
"@types/node": "^20.19.17",
"@types/react": "^18.3.12",
"@vitejs/plugin-react": "^4.7.0",
"react": "^18.3.1",
"rimraf": "^5.0.0",
"typescript": "^5.8.3",
"vite": "^6.0.7",
@@ -56,7 +58,6 @@
"repository": {
"type": "git",
"url": "https://github.com/esengine/esengine.git",
"directory": "packages/node-editor"
},
"private": true
"directory": "packages/devtools/node-editor"
}
}

View File

@@ -50,6 +50,15 @@ export interface GraphCanvasProps {
/** Canvas context menu callback (画布右键菜单回调) */
onContextMenu?: (position: Position, e: React.MouseEvent) => void;
/** Canvas mouse down callback for box selection (画布鼠标按下回调,用于框选) */
onMouseDown?: (position: Position, e: React.MouseEvent) => void;
/** Canvas mouse move callback (画布鼠标移动回调) */
onCanvasMouseMove?: (position: Position, e: React.MouseEvent) => void;
/** Canvas mouse up callback (画布鼠标释放回调) */
onCanvasMouseUp?: (position: Position, e: React.MouseEvent) => void;
/** Children to render (要渲染的子元素) */
children?: React.ReactNode;
}
@@ -75,6 +84,9 @@ export const GraphCanvas: React.FC<GraphCanvasProps> = ({
onZoomChange,
onClick,
onContextMenu,
onMouseDown: onMouseDownProp,
onCanvasMouseMove,
onCanvasMouseUp,
children
}) => {
const containerRef = useRef<HTMLDivElement>(null);
@@ -132,22 +144,30 @@ export const GraphCanvas: React.FC<GraphCanvasProps> = ({
}, [zoom, pan, minZoom, maxZoom, updateZoom, updatePan]);
/**
* Handles mouse down for panning
*
* Handles mouse down for panning or box selection
*
*/
const handleMouseDown = useCallback((e: React.MouseEvent) => {
// Middle mouse button or space + left click for panning
// 中键或空格+左键平移
// Middle mouse button or Alt + left click for panning
// 中键或 Alt+左键平移
if (e.button === 1 || (e.button === 0 && e.altKey)) {
e.preventDefault();
setIsPanning(true);
lastMousePos.current = new Position(e.clientX, e.clientY);
} else if (e.button === 0) {
// Left click on canvas background - start box selection
// 左键点击画布背景 - 开始框选
const target = e.target as HTMLElement;
if (target === containerRef.current || target.classList.contains('ne-canvas-content')) {
const canvasPos = screenToCanvas(e.clientX, e.clientY);
onMouseDownProp?.(canvasPos, e);
}
}
}, []);
}, [screenToCanvas, onMouseDownProp]);
/**
* Handles mouse move for panning
*
* Handles mouse move for panning or box selection
*
*/
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (isPanning) {
@@ -157,13 +177,27 @@ export const GraphCanvas: React.FC<GraphCanvasProps> = ({
const newPan = new Position(pan.x + dx, pan.y + dy);
updatePan(newPan);
}
}, [isPanning, pan, updatePan]);
// Always call canvas mouse move for box selection
// 始终调用画布鼠标移动回调用于框选
const canvasPos = screenToCanvas(e.clientX, e.clientY);
onCanvasMouseMove?.(canvasPos, e);
}, [isPanning, pan, updatePan, screenToCanvas, onCanvasMouseMove]);
/**
* Handles mouse up to stop panning
*
* Handles mouse up to stop panning or box selection
*
*/
const handleMouseUp = useCallback(() => {
const handleMouseUp = useCallback((e: React.MouseEvent) => {
setIsPanning(false);
const canvasPos = screenToCanvas(e.clientX, e.clientY);
onCanvasMouseUp?.(canvasPos, e);
}, [screenToCanvas, onCanvasMouseUp]);
/**
* Handles mouse leave to stop panning
*
*/
const handleMouseLeave = useCallback(() => {
setIsPanning(false);
}, []);
@@ -276,7 +310,7 @@ export const GraphCanvas: React.FC<GraphCanvasProps> = ({
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
onContextMenu={handleContextMenu}
>

View File

@@ -1,11 +1,13 @@
import React, { useRef, useCallback, useState, useMemo } from 'react';
import React, { useRef, useCallback, useState, useMemo, useEffect } from 'react';
import { Graph } from '../../domain/models/Graph';
import { GraphNode, NodeTemplate } from '../../domain/models/GraphNode';
import { Connection } from '../../domain/models/Connection';
import { Pin } from '../../domain/models/Pin';
import { Position } from '../../domain/value-objects/Position';
import { NodeGroup, computeGroupBounds, estimateNodeHeight } from '../../domain/models/NodeGroup';
import { GraphCanvas } from '../canvas/GraphCanvas';
import { MemoizedGraphNodeComponent, NodeExecutionState } from '../nodes/GraphNodeComponent';
import { MemoizedGroupNodeComponent } from '../nodes/GroupNodeComponent';
import { ConnectionLayer } from '../connections/ConnectionLine';
/**
@@ -56,6 +58,12 @@ export interface NodeEditorProps {
/** Connection context menu callback (连接右键菜单回调) */
onConnectionContextMenu?: (connection: Connection, e: React.MouseEvent) => void;
/** Group context menu callback (组右键菜单回调) */
onGroupContextMenu?: (group: NodeGroup, e: React.MouseEvent) => void;
/** Group double click callback - typically used to expand group (组双击回调 - 通常用于展开组) */
onGroupDoubleClick?: (group: NodeGroup) => void;
}
/**
@@ -80,6 +88,16 @@ interface ConnectionDragState {
isValid?: boolean;
}
/**
* Box selection state
*
*/
interface BoxSelectState {
startPos: Position;
currentPos: Position;
additive: boolean;
}
/**
* NodeEditor - Complete node graph editor component
* NodeEditor -
@@ -102,7 +120,9 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onNodeDoubleClick: _onNodeDoubleClick,
onCanvasContextMenu,
onNodeContextMenu,
onConnectionContextMenu
onConnectionContextMenu,
onGroupContextMenu,
onGroupDoubleClick
}) => {
// Silence unused variable warnings (消除未使用变量警告)
void _templates;
@@ -126,6 +146,88 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
const [dragState, setDragState] = useState<DragState | null>(null);
const [connectionDrag, setConnectionDrag] = useState<ConnectionDragState | null>(null);
const [hoveredPin, setHoveredPin] = useState<Pin | null>(null);
const [boxSelectState, setBoxSelectState] = useState<BoxSelectState | null>(null);
// Track if box selection just ended to prevent click from clearing selection
// 跟踪框选是否刚刚结束,以防止 click 清除选择
const boxSelectJustEndedRef = useRef(false);
// Force re-render after mount to ensure connections are drawn correctly
// 挂载后强制重渲染以确保连接线正确绘制
const [, forceUpdate] = useState(0);
// Track collapsed state to force connection re-render
// 跟踪折叠状态以强制连接线重渲染
const collapsedNodesKey = useMemo(() => {
return graph.nodes.map(n => `${n.id}:${n.isCollapsed}`).join(',');
}, [graph.nodes]);
// Groups are now simple visual boxes - no node hiding
// 组现在是简单的可视化框 - 不隐藏节点
// Track selected group IDs (local state, managed similarly to nodes)
// 跟踪选中的组ID本地状态类似节点管理方式
const [selectedGroupIds, setSelectedGroupIds] = useState<Set<string>>(new Set());
// Group drag state - includes initial positions of nodes in the group
// 组拖拽状态 - 包含组内节点的初始位置
const [groupDragState, setGroupDragState] = useState<{
groupId: string;
startGroupPosition: Position;
startMouse: { x: number; y: number };
nodeStartPositions: Map<string, Position>;
} | null>(null);
// Key for tracking group changes
const groupsKey = useMemo(() => {
return graph.groups.map(g => `${g.id}:${g.position.x}:${g.position.y}`).join(',');
}, [graph.groups]);
// Compute dynamic group bounds based on current node positions and sizes
// 根据当前节点位置和尺寸动态计算组边界
const groupsWithDynamicBounds = useMemo(() => {
const defaultNodeWidth = 200;
return graph.groups.map(group => {
// Get current bounds of all nodes in this group
const nodeBounds = group.nodeIds
.map(nodeId => graph.getNode(nodeId))
.filter((node): node is GraphNode => node !== undefined)
.map(node => ({
x: node.position.x,
y: node.position.y,
width: defaultNodeWidth,
height: estimateNodeHeight(
node.inputPins.length,
node.outputPins.length,
node.isCollapsed
)
}));
if (nodeBounds.length === 0) {
// No nodes found, use stored position/size as fallback
return group;
}
// Calculate dynamic bounds based on actual node sizes
const { position, size } = computeGroupBounds(nodeBounds);
return {
...group,
position,
size
};
});
}, [graph.groups, graph.nodes]);
useEffect(() => {
// Use requestAnimationFrame to wait for DOM to be fully rendered
// 使用 requestAnimationFrame 等待 DOM 完全渲染
const rafId = requestAnimationFrame(() => {
forceUpdate(n => n + 1);
});
return () => cancelAnimationFrame(rafId);
}, [graph.id, collapsedNodesKey, groupsKey]);
/**
* Converts screen coordinates to canvas coordinates
@@ -146,21 +248,52 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
*
*
* DOM
*
*
*/
const getPinPosition = useCallback((pinId: string): Position | undefined => {
// First, find which node this pin belongs to
// 首先查找该引脚属于哪个节点
let ownerNode: GraphNode | undefined;
for (const node of graph.nodes) {
if (node.allPins.some(p => p.id === pinId)) {
ownerNode = node;
break;
}
}
if (!ownerNode) return undefined;
// Find the pin element and its parent node
const pinElement = containerRef.current?.querySelector(`[data-pin-id="${pinId}"]`) as HTMLElement;
if (!pinElement) return undefined;
// If pin element not found (e.g., node is collapsed), use node header position
// 如果找不到引脚元素(例如节点已收缩),使用节点头部位置
if (!pinElement) {
const nodeElement = containerRef.current?.querySelector(`[data-node-id="${ownerNode.id}"]`) as HTMLElement;
if (!nodeElement) return undefined;
const nodeRect = nodeElement.getBoundingClientRect();
const { zoom } = transformRef.current;
// Find the pin to determine if it's input or output
const pin = ownerNode.allPins.find(p => p.id === pinId);
const isOutput = pin?.isOutput ?? false;
// For collapsed nodes, position at the right side for outputs, left side for inputs
// 对于收缩的节点,输出引脚在右侧,输入引脚在左侧
const headerHeight = 28; // Approximate header height
const relativeX = isOutput ? nodeRect.width / zoom : 0;
const relativeY = headerHeight / 2;
return new Position(
ownerNode.position.x + relativeX,
ownerNode.position.y + relativeY
);
}
const nodeElement = pinElement.closest('[data-node-id]') as HTMLElement;
if (!nodeElement) return undefined;
const nodeId = nodeElement.getAttribute('data-node-id');
if (!nodeId) return undefined;
const node = graph.getNode(nodeId);
if (!node) return undefined;
// Get pin position relative to node element (in unscaled pixels)
const nodeRect = nodeElement.getBoundingClientRect();
const pinRect = pinElement.getBoundingClientRect();
@@ -172,8 +305,8 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
// Final position = node position + relative position
return new Position(
node.position.x + relativeX,
node.position.y + relativeY
ownerNode.position.x + relativeX,
ownerNode.position.y + relativeY
);
}, [graph]);
@@ -255,6 +388,22 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onGraphChange?.(newGraph);
}
// Group dragging - moves all nodes inside (group bounds are dynamic)
// 组拖拽 - 移动组内所有节点(组边界是动态计算的)
if (groupDragState) {
const dx = mousePos.x - groupDragState.startMouse.x;
const dy = mousePos.y - groupDragState.startMouse.y;
// Only move nodes - group bounds will auto-recalculate
let newGraph = graph;
for (const [nodeId, startPos] of groupDragState.nodeStartPositions) {
const newNodePos = new Position(startPos.x + dx, startPos.y + dy);
newGraph = newGraph.moveNode(nodeId, newNodePos);
}
onGraphChange?.(newGraph);
}
// Connection dragging (连接拖拽)
if (connectionDrag) {
const isValid = hoveredPin ? connectionDrag.fromPin.canConnectTo(hoveredPin) : undefined;
@@ -266,7 +415,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
isValid
} : null);
}
}, [graph, dragState, connectionDrag, hoveredPin, screenToCanvas, onGraphChange]);
}, [graph, dragState, groupDragState, connectionDrag, hoveredPin, screenToCanvas, onGraphChange]);
/**
* Handles mouse up to end dragging
@@ -278,6 +427,11 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
setDragState(null);
}
// End group dragging (结束组拖拽)
if (groupDragState) {
setGroupDragState(null);
}
// End connection dragging (结束连接拖拽)
if (connectionDrag) {
// Use hoveredPin directly instead of relying on async state update
@@ -312,7 +466,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
setConnectionDrag(null);
}
}, [graph, dragState, connectionDrag, hoveredPin, onGraphChange]);
}, [graph, dragState, groupDragState, connectionDrag, hoveredPin, onGraphChange]);
/**
* Handles pin mouse down
@@ -423,13 +577,95 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
}
}, [graph, onConnectionContextMenu]);
/**
* Handles group selection
*
*/
const handleGroupSelect = useCallback((groupId: string, additive: boolean) => {
if (readOnly) return;
const newSelection = new Set(selectedGroupIds);
if (additive) {
if (newSelection.has(groupId)) {
newSelection.delete(groupId);
} else {
newSelection.add(groupId);
}
} else {
newSelection.clear();
newSelection.add(groupId);
}
setSelectedGroupIds(newSelection);
// Clear node and connection selection when selecting groups
onSelectionChange?.(new Set(), new Set());
}, [selectedGroupIds, readOnly, onSelectionChange]);
/**
* Handles group drag start
*
*
* Captures initial positions of both the group and all nodes inside it
*
*/
const handleGroupDragStart = useCallback((groupId: string, startMouse: { x: number; y: number }) => {
if (readOnly) return;
const group = graph.getGroup(groupId);
if (!group) return;
// Convert screen coordinates to canvas coordinates (same as node dragging)
// 将屏幕坐标转换为画布坐标(与节点拖拽相同)
const canvasPos = screenToCanvas(startMouse.x, startMouse.y);
// Capture initial positions of all nodes in the group
const nodeStartPositions = new Map<string, Position>();
for (const nodeId of group.nodeIds) {
const node = graph.getNode(nodeId);
if (node) {
nodeStartPositions.set(nodeId, node.position);
}
}
setGroupDragState({
groupId,
startGroupPosition: group.position,
startMouse: { x: canvasPos.x, y: canvasPos.y },
nodeStartPositions
});
}, [graph, readOnly, screenToCanvas]);
/**
* Handles group context menu
*
*/
const handleGroupContextMenu = useCallback((group: NodeGroup, e: React.MouseEvent) => {
onGroupContextMenu?.(group, e);
}, [onGroupContextMenu]);
/**
* Handles group double click
*
*/
const handleGroupDoubleClick = useCallback((group: NodeGroup) => {
onGroupDoubleClick?.(group);
}, [onGroupDoubleClick]);
/**
* Handles canvas click to deselect
*
*/
const handleCanvasClick = useCallback((_position: Position, _e: React.MouseEvent) => {
// Skip if box selection just ended (click fires after mouseup)
// 如果框选刚刚结束则跳过click 在 mouseup 之后触发)
if (boxSelectJustEndedRef.current) {
boxSelectJustEndedRef.current = false;
return;
}
if (!readOnly) {
onSelectionChange?.(new Set(), new Set());
setSelectedGroupIds(new Set());
}
}, [readOnly, onSelectionChange]);
@@ -441,6 +677,79 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onCanvasContextMenu?.(position, e);
}, [onCanvasContextMenu]);
/**
* Handles box selection start
*
*/
const handleBoxSelectStart = useCallback((position: Position, e: React.MouseEvent) => {
if (readOnly) return;
setBoxSelectState({
startPos: position,
currentPos: position,
additive: e.ctrlKey || e.metaKey
});
}, [readOnly]);
/**
* Handles box selection move
*
*/
const handleBoxSelectMove = useCallback((position: Position) => {
if (boxSelectState) {
setBoxSelectState(prev => prev ? { ...prev, currentPos: position } : null);
}
}, [boxSelectState]);
/**
* Handles box selection end
*
*/
const handleBoxSelectEnd = useCallback(() => {
if (!boxSelectState) return;
const { startPos, currentPos, additive } = boxSelectState;
// Calculate selection box bounds
const minX = Math.min(startPos.x, currentPos.x);
const maxX = Math.max(startPos.x, currentPos.x);
const minY = Math.min(startPos.y, currentPos.y);
const maxY = Math.max(startPos.y, currentPos.y);
// Find nodes within the selection box
const nodesInBox: string[] = [];
const nodeWidth = 200; // Approximate node width
const nodeHeight = 100; // Approximate node height
for (const node of graph.nodes) {
const nodeLeft = node.position.x;
const nodeTop = node.position.y;
const nodeRight = nodeLeft + nodeWidth;
const nodeBottom = nodeTop + nodeHeight;
// Check if node intersects with selection box
const intersects = !(nodeRight < minX || nodeLeft > maxX || nodeBottom < minY || nodeTop > maxY);
if (intersects) {
nodesInBox.push(node.id);
}
}
// Update selection
if (additive) {
// Add to existing selection
const newSelection = new Set(selectedNodeIds);
nodesInBox.forEach(id => newSelection.add(id));
onSelectionChange?.(newSelection, new Set());
} else {
// Replace selection
onSelectionChange?.(new Set(nodesInBox), new Set());
}
// Mark that box selection just ended to prevent click from clearing selection
// 标记框选刚刚结束,以防止 click 清除选择
boxSelectJustEndedRef.current = true;
setBoxSelectState(null);
}, [boxSelectState, graph.nodes, selectedNodeIds, onSelectionChange]);
/**
* Handles pin value change
*
@@ -497,7 +806,25 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onContextMenu={handleCanvasContextMenu}
onPanChange={handlePanChange}
onZoomChange={handleZoomChange}
onMouseDown={handleBoxSelectStart}
onCanvasMouseMove={handleBoxSelectMove}
onCanvasMouseUp={handleBoxSelectEnd}
>
{/* Group boxes - rendered first so they appear behind nodes (组框 - 先渲染,这样显示在节点后面) */}
{/* Use dynamically calculated bounds so groups auto-resize to fit nodes */}
{groupsWithDynamicBounds.map(group => (
<MemoizedGroupNodeComponent
key={group.id}
group={group}
isSelected={selectedGroupIds.has(group.id)}
isDragging={groupDragState?.groupId === group.id}
onSelect={handleGroupSelect}
onDragStart={handleGroupDragStart}
onContextMenu={handleGroupContextMenu}
onDoubleClick={handleGroupDoubleClick}
/>
))}
{/* Connection layer (连接层) */}
<ConnectionLayer
connections={graph.connections}
@@ -509,7 +836,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onConnectionContextMenu={handleConnectionContextMenu}
/>
{/* Nodes (节点) */}
{/* All Nodes (所有节点) */}
{graph.nodes.map(node => (
<MemoizedGraphNodeComponent
key={node.id}
@@ -531,6 +858,19 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onToggleCollapse={handleToggleCollapse}
/>
))}
{/* Box selection overlay (框选覆盖层) */}
{boxSelectState && (
<div
className="ne-selection-box"
style={{
left: Math.min(boxSelectState.startPos.x, boxSelectState.currentPos.x),
top: Math.min(boxSelectState.startPos.y, boxSelectState.currentPos.y),
width: Math.abs(boxSelectState.currentPos.x - boxSelectState.startPos.x),
height: Math.abs(boxSelectState.currentPos.y - boxSelectState.startPos.y)
}}
/>
)}
</GraphCanvas>
</div>
);

View File

@@ -64,6 +64,8 @@ export const GraphNodeComponent: React.FC<GraphNodeComponentProps> = ({
return draggingFromPin.canConnectTo(pin);
}, [draggingFromPin]);
const hasError = Boolean(node.data.invalidVariable);
const classNames = useMemo(() => {
const classes = ['ne-node'];
if (isSelected) classes.push('selected');
@@ -71,8 +73,9 @@ export const GraphNodeComponent: React.FC<GraphNodeComponentProps> = ({
if (node.isCollapsed) classes.push('collapsed');
if (node.category === 'comment') classes.push('comment');
if (executionState !== 'idle') classes.push(executionState);
if (hasError) classes.push('has-error');
return classes.join(' ');
}, [isSelected, isDragging, node.isCollapsed, node.category, executionState]);
}, [isSelected, isDragging, node.isCollapsed, node.category, executionState, hasError]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
@@ -102,8 +105,10 @@ export const GraphNodeComponent: React.FC<GraphNodeComponentProps> = ({
: undefined;
// Separate exec pins from data pins
// Also filter out pins listed in data.hiddenInputs
const hiddenInputs = (node.data.hiddenInputs as string[]) || [];
const inputExecPins = node.inputPins.filter(p => p.isExec && !p.hidden);
const inputDataPins = node.inputPins.filter(p => !p.isExec && !p.hidden);
const inputDataPins = node.inputPins.filter(p => !p.isExec && !p.hidden && !hiddenInputs.includes(p.name));
const outputExecPins = node.outputPins.filter(p => p.isExec && !p.hidden);
const outputDataPins = node.outputPins.filter(p => !p.isExec && !p.hidden);
@@ -129,13 +134,17 @@ export const GraphNodeComponent: React.FC<GraphNodeComponentProps> = ({
className={`ne-node-header ${node.category}`}
style={headerStyle}
>
{/* Diamond icon for event nodes, or custom icon */}
{/* Warning icon for invalid nodes, or diamond/custom icon */}
<span className="ne-node-header-icon">
{node.icon && renderIcon ? renderIcon(node.icon) : null}
{hasError ? (
<span className="ne-node-warning-icon" title={`Variable '${node.data.variableName}' not found`}></span>
) : (
node.icon && renderIcon ? renderIcon(node.icon) : null
)}
</span>
<span className="ne-node-header-title">
{node.title}
{(node.data.displayTitle as string) || node.title}
{node.subtitle && (
<span className="ne-node-header-subtitle">
{node.subtitle}

View File

@@ -0,0 +1,124 @@
import React, { useCallback, useMemo, useRef } from 'react';
import { NodeGroup } from '../../domain/models/NodeGroup';
import '../../styles/GroupNode.css';
export interface GroupNodeComponentProps {
/** The group to render */
group: NodeGroup;
/** Whether the group is selected */
isSelected?: boolean;
/** Whether the group is being dragged */
isDragging?: boolean;
/** Selection handler */
onSelect?: (groupId: string, additive: boolean) => void;
/** Drag start handler */
onDragStart?: (groupId: string, startPosition: { x: number; y: number }) => void;
/** Context menu handler */
onContextMenu?: (group: NodeGroup, e: React.MouseEvent) => void;
/** Double click handler for editing name */
onDoubleClick?: (group: NodeGroup) => void;
}
/**
* GroupNodeComponent - Renders a visual group box around nodes
* GroupNodeComponent - 渲染节点周围的可视化组框
*
* This is a simple background box that provides visual organization.
* 这是一个简单的背景框,提供视觉组织功能。
*/
export const GroupNodeComponent: React.FC<GroupNodeComponentProps> = ({
group,
isSelected = false,
isDragging = false,
onSelect,
onDragStart,
onContextMenu,
onDoubleClick
}) => {
const groupRef = useRef<HTMLDivElement>(null);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
// Only handle clicks on the header or border, not on the content area
const target = e.target as HTMLElement;
if (!target.closest('.ne-group-box-header') && !target.classList.contains('ne-group-box')) {
return;
}
e.stopPropagation();
const additive = e.ctrlKey || e.metaKey;
onSelect?.(group.id, additive);
onDragStart?.(group.id, { x: e.clientX, y: e.clientY });
}, [group.id, onSelect, onDragStart]);
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onContextMenu?.(group, e);
}, [group, onContextMenu]);
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
// Only handle double-click on the header
const target = e.target as HTMLElement;
if (!target.closest('.ne-group-box-header')) {
return;
}
e.stopPropagation();
onDoubleClick?.(group);
}, [group, onDoubleClick]);
const classNames = useMemo(() => {
const classes = ['ne-group-box'];
if (isSelected) classes.push('selected');
if (isDragging) classes.push('dragging');
return classes.join(' ');
}, [isSelected, isDragging]);
const style: React.CSSProperties = useMemo(() => ({
left: group.position.x,
top: group.position.y,
width: group.size.width,
height: group.size.height,
'--group-color': group.color || 'rgba(100, 149, 237, 0.15)'
} as React.CSSProperties), [group.position.x, group.position.y, group.size.width, group.size.height, group.color]);
return (
<div
ref={groupRef}
className={classNames}
style={style}
data-group-id={group.id}
onMouseDown={handleMouseDown}
onContextMenu={handleContextMenu}
onDoubleClick={handleDoubleClick}
>
<div className="ne-group-box-header">
<span className="ne-group-box-title">{group.name}</span>
</div>
</div>
);
};
/**
* Memoized version for performance
*/
export const MemoizedGroupNodeComponent = React.memo(GroupNodeComponent, (prev, next) => {
if (prev.group.id !== next.group.id) return false;
if (prev.isSelected !== next.isSelected) return false;
if (prev.isDragging !== next.isDragging) return false;
if (prev.group.position.x !== next.group.position.x ||
prev.group.position.y !== next.group.position.y) return false;
if (prev.group.size.width !== next.group.size.width ||
prev.group.size.height !== next.group.size.height) return false;
if (prev.group.name !== next.group.name) return false;
if (prev.group.color !== next.group.color) return false;
return true;
});
export default GroupNodeComponent;

View File

@@ -4,3 +4,9 @@ export {
type GraphNodeComponentProps,
type NodeExecutionState
} from './GraphNodeComponent';
export {
GroupNodeComponent,
MemoizedGroupNodeComponent,
type GroupNodeComponentProps
} from './GroupNodeComponent';

View File

@@ -2,6 +2,7 @@ import { GraphNode } from './GraphNode';
import { Connection } from './Connection';
import { Pin } from './Pin';
import { Position } from '../value-objects/Position';
import { NodeGroup, serializeNodeGroup } from './NodeGroup';
/**
* Graph - Aggregate root for the node graph
@@ -15,6 +16,7 @@ export class Graph {
private readonly _name: string;
private readonly _nodes: Map<string, GraphNode>;
private readonly _connections: Connection[];
private readonly _groups: NodeGroup[];
private readonly _metadata: Record<string, unknown>;
constructor(
@@ -22,12 +24,14 @@ export class Graph {
name: string,
nodes: GraphNode[] = [],
connections: Connection[] = [],
metadata: Record<string, unknown> = {}
metadata: Record<string, unknown> = {},
groups: NodeGroup[] = []
) {
this._id = id;
this._name = name;
this._nodes = new Map(nodes.map(n => [n.id, n]));
this._connections = [...connections];
this._groups = [...groups];
this._metadata = { ...metadata };
}
@@ -59,6 +63,29 @@ export class Graph {
return this._connections.length;
}
/**
* Gets all groups ()
*/
get groups(): NodeGroup[] {
return [...this._groups];
}
/**
* Gets a group by ID
* ID获取组
*/
getGroup(groupId: string): NodeGroup | undefined {
return this._groups.find(g => g.id === groupId);
}
/**
* Gets the group containing a specific node
*
*/
getNodeGroup(nodeId: string): NodeGroup | undefined {
return this._groups.find(g => g.nodeIds.includes(nodeId));
}
/**
* Gets a node by ID
* ID获取节点
@@ -112,7 +139,7 @@ export class Graph {
throw new Error(`Node with ID "${node.id}" already exists`);
}
const newNodes = [...this.nodes, node];
return new Graph(this._id, this._name, newNodes, this._connections, this._metadata);
return new Graph(this._id, this._name, newNodes, this._connections, this._metadata, this._groups);
}
/**
@@ -125,7 +152,14 @@ export class Graph {
}
const newNodes = this.nodes.filter(n => n.id !== nodeId);
const newConnections = this._connections.filter(c => !c.involvesNode(nodeId));
return new Graph(this._id, this._name, newNodes, newConnections, this._metadata);
// Also remove the node from any groups it belongs to
const newGroups = this._groups.map(g => {
if (g.nodeIds.includes(nodeId)) {
return { ...g, nodeIds: g.nodeIds.filter(id => id !== nodeId) };
}
return g;
}).filter(g => g.nodeIds.length > 0); // Remove empty groups
return new Graph(this._id, this._name, newNodes, newConnections, this._metadata, newGroups);
}
/**
@@ -138,7 +172,7 @@ export class Graph {
const updatedNode = updater(node);
const newNodes = this.nodes.map(n => n.id === nodeId ? updatedNode : n);
return new Graph(this._id, this._name, newNodes, this._connections, this._metadata);
return new Graph(this._id, this._name, newNodes, this._connections, this._metadata, this._groups);
}
/**
@@ -184,7 +218,7 @@ export class Graph {
}
newConnections.push(connection);
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata);
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata, this._groups);
}
/**
@@ -196,7 +230,7 @@ export class Graph {
if (newConnections.length === this._connections.length) {
return this;
}
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata);
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata, this._groups);
}
/**
@@ -208,7 +242,47 @@ export class Graph {
if (newConnections.length === this._connections.length) {
return this;
}
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata);
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata, this._groups);
}
// ========== Group Operations (组操作) ==========
/**
* Adds a new group (immutable)
*
*/
addGroup(group: NodeGroup): Graph {
if (this._groups.some(g => g.id === group.id)) {
throw new Error(`Group with ID "${group.id}" already exists`);
}
const newGroups = [...this._groups, group];
return new Graph(this._id, this._name, this.nodes, this._connections, this._metadata, newGroups);
}
/**
* Removes a group (immutable)
*
*/
removeGroup(groupId: string): Graph {
const newGroups = this._groups.filter(g => g.id !== groupId);
if (newGroups.length === this._groups.length) {
return this;
}
return new Graph(this._id, this._name, this.nodes, this._connections, this._metadata, newGroups);
}
/**
* Updates a group (immutable)
*
*/
updateGroup(groupId: string, updater: (group: NodeGroup) => NodeGroup): Graph {
const groupIndex = this._groups.findIndex(g => g.id === groupId);
if (groupIndex === -1) return this;
const updatedGroup = updater(this._groups[groupIndex]);
const newGroups = [...this._groups];
newGroups[groupIndex] = updatedGroup;
return new Graph(this._id, this._name, this.nodes, this._connections, this._metadata, newGroups);
}
/**
@@ -219,7 +293,7 @@ export class Graph {
return new Graph(this._id, this._name, this.nodes, this._connections, {
...this._metadata,
...metadata
});
}, this._groups);
}
/**
@@ -227,7 +301,7 @@ export class Graph {
*
*/
rename(newName: string): Graph {
return new Graph(this._id, newName, this.nodes, this._connections, this._metadata);
return new Graph(this._id, newName, this.nodes, this._connections, this._metadata, this._groups);
}
/**
@@ -257,6 +331,7 @@ export class Graph {
name: this._name,
nodes: this.nodes.map(n => n.toJSON()),
connections: this._connections.map(c => c.toJSON()),
groups: this._groups.map(g => serializeNodeGroup(g)),
metadata: this._metadata
};
}

View File

@@ -0,0 +1,146 @@
import { Position } from '../value-objects/Position';
/**
* NodeGroup - Represents a visual group box around nodes
* NodeGroup - 表示节点周围的可视化组框
*
* Groups are purely visual organization - they don't affect runtime execution.
* 组是纯视觉组织 - 不影响运行时执行。
*/
export interface NodeGroup {
/** Unique identifier for the group */
id: string;
/** Display name of the group */
name: string;
/** IDs of nodes contained in this group */
nodeIds: string[];
/** Position of the group box (top-left corner) */
position: Position;
/** Size of the group box */
size: { width: number; height: number };
/** Optional color for the group box */
color?: string;
}
/**
* Creates a new NodeGroup with the given properties
*/
export function createNodeGroup(
id: string,
name: string,
nodeIds: string[],
position: Position,
size: { width: number; height: number },
color?: string
): NodeGroup {
return {
id,
name,
nodeIds: [...nodeIds],
position,
size,
color
};
}
/**
* Node bounds info for group calculation
* 用于组计算的节点边界信息
*/
export interface NodeBounds {
x: number;
y: number;
width: number;
height: number;
}
/**
* Estimates node height based on pin count
* 根据引脚数量估算节点高度
*/
export function estimateNodeHeight(inputPinCount: number, outputPinCount: number, isCollapsed: boolean = false): number {
if (isCollapsed) {
return 32; // Just header
}
const headerHeight = 32;
const pinHeight = 26;
const bottomPadding = 12;
const maxPins = Math.max(inputPinCount, outputPinCount);
return headerHeight + maxPins * pinHeight + bottomPadding;
}
/**
* Computes the bounding box for a group based on its nodes
* Returns position (top-left) and size with padding
*
* @param nodeBounds - Array of node bounds (position + size)
* @param padding - Padding around the group box
*/
export function computeGroupBounds(
nodeBounds: NodeBounds[],
padding: number = 30
): { position: Position; size: { width: number; height: number } } {
if (nodeBounds.length === 0) {
return {
position: new Position(0, 0),
size: { width: 250, height: 150 }
};
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const node of nodeBounds) {
minX = Math.min(minX, node.x);
minY = Math.min(minY, node.y);
maxX = Math.max(maxX, node.x + node.width);
maxY = Math.max(maxY, node.y + node.height);
}
// Add padding and header space for group title
const groupHeaderHeight = 28;
return {
position: new Position(minX - padding, minY - padding - groupHeaderHeight),
size: {
width: maxX - minX + padding * 2,
height: maxY - minY + padding * 2 + groupHeaderHeight
}
};
}
/**
* Serializes a NodeGroup for JSON storage
*/
export function serializeNodeGroup(group: NodeGroup): Record<string, unknown> {
return {
id: group.id,
name: group.name,
nodeIds: [...group.nodeIds],
position: { x: group.position.x, y: group.position.y },
size: { width: group.size.width, height: group.size.height },
color: group.color
};
}
/**
* Deserializes a NodeGroup from JSON
*/
export function deserializeNodeGroup(data: Record<string, unknown>): NodeGroup {
const pos = data.position as { x: number; y: number } | undefined;
const size = data.size as { width: number; height: number } | undefined;
return {
id: data.id as string,
name: data.name as string,
nodeIds: (data.nodeIds as string[]) || [],
position: new Position(pos?.x || 0, pos?.y || 0),
size: size || { width: 250, height: 150 },
color: data.color as string | undefined
};
}

View File

@@ -2,3 +2,12 @@ export { Pin, type PinDefinition } from './Pin';
export { GraphNode, type NodeTemplate, type NodeCategory } from './GraphNode';
export { Connection } from './Connection';
export { Graph } from './Graph';
export {
type NodeGroup,
type NodeBounds,
createNodeGroup,
computeGroupBounds,
estimateNodeHeight,
serializeNodeGroup,
deserializeNodeGroup
} from './NodeGroup';

View File

@@ -10,6 +10,9 @@
// Import styles (导入样式)
import './styles/index.css';
// CSS utilities for Shadow DOM (Shadow DOM 的 CSS 工具)
export { nodeEditorCssText, injectNodeEditorStyles } from './styles/cssText';
// Domain models (领域模型)
export {
// Models
@@ -20,7 +23,15 @@ export {
// Types
type NodeTemplate,
type NodeCategory,
type PinDefinition
type PinDefinition,
// NodeGroup
type NodeGroup,
type NodeBounds,
createNodeGroup,
computeGroupBounds,
estimateNodeHeight,
serializeNodeGroup,
deserializeNodeGroup
} from './domain/models';
// Value objects (值对象)

View File

@@ -38,6 +38,24 @@
z-index: var(--ne-z-dragging);
}
/* Error state for invalid variable references */
.ne-node.has-error {
border-color: #e74c3c;
box-shadow: 0 0 0 1px #e74c3c,
0 0 12px rgba(231, 76, 60, 0.4),
0 4px 8px rgba(0, 0, 0, 0.4);
}
.ne-node.has-error .ne-node-header {
background: linear-gradient(180deg, #c0392b 0%, #962d22 100%) !important;
}
.ne-node-warning-icon {
color: #f1c40f;
font-size: 14px;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
}
/* ==================== Node Header 节点头部 ==================== */
.ne-node-header {
display: flex;
@@ -263,3 +281,32 @@
0 0 20px rgba(255, 167, 38, 0.7);
}
}
/* ==================== Group Node 组节点 ==================== */
.ne-node.ne-group-node {
border: 2px dashed rgba(100, 149, 237, 0.6);
background: rgba(40, 60, 90, 0.85);
}
.ne-node.ne-group-node:hover {
border-color: rgba(100, 149, 237, 0.8);
}
.ne-node.ne-group-node.selected {
border-color: #6495ed;
box-shadow: 0 0 0 1px #6495ed,
0 0 16px rgba(100, 149, 237, 0.5),
0 4px 12px rgba(0, 0, 0, 0.5);
}
.ne-group-header {
background: linear-gradient(90deg, #3a5f8a 0%, #2a4a6a 100%) !important;
}
.ne-group-hint {
padding: 8px 12px;
font-size: 11px;
color: #8899aa;
font-style: italic;
text-align: center;
}

View File

@@ -0,0 +1,60 @@
/**
* Group Box Styles
* 组框样式
*/
/* ==================== Group Box Container 组框容器 ==================== */
.ne-group-box {
position: absolute;
background: var(--group-color, rgba(100, 149, 237, 0.15));
border: 2px dashed rgba(100, 149, 237, 0.5);
border-radius: 8px;
pointer-events: auto;
z-index: 0;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.ne-group-box:hover {
border-color: rgba(100, 149, 237, 0.7);
}
.ne-group-box.selected {
border-color: var(--ne-node-border-selected, #e5a020);
box-shadow: 0 0 0 1px var(--ne-node-border-selected, #e5a020),
0 0 12px rgba(229, 160, 32, 0.3);
}
.ne-group-box.dragging {
opacity: 0.9;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
/* ==================== Group Box Header 组框头部 ==================== */
.ne-group-box-header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 28px;
display: flex;
align-items: center;
padding: 0 10px;
background: rgba(100, 149, 237, 0.3);
border-radius: 6px 6px 0 0;
cursor: grab;
user-select: none;
}
.ne-group-box-header:active {
cursor: grabbing;
}
.ne-group-box-title {
color: #fff;
font-size: 12px;
font-weight: 500;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,55 @@
/**
* @zh 节点编辑器 CSS 样式文本
* @en Node Editor CSS style text
*
* @zh 此文件在构建时由 vite 插件自动生成
* @en This file is auto-generated by vite plugin during build
*/
// Placeholder - will be replaced by vite plugin during build
export const nodeEditorCssText = '__NODE_EDITOR_CSS_PLACEHOLDER__';
/**
* @zh 将 CSS 注入到指定的根节点(支持 Shadow DOM
* @en Inject CSS into specified root node (supports Shadow DOM)
*
* @param root - @zh 目标根节点Document 或 ShadowRoot@en Target root node (Document or ShadowRoot)
* @param styleId - @zh 样式标签的 ID @en ID for the style tag
* @returns @zh 创建的 style 元素 @en The created style element
*
* @example
* ```typescript
* // Inject into Shadow DOM
* const shadowRoot = element.attachShadow({ mode: 'open' });
* injectNodeEditorStyles(shadowRoot);
*
* // Inject into document (with custom ID)
* injectNodeEditorStyles(document, 'my-editor-styles');
* ```
*/
export function injectNodeEditorStyles(
root: Document | ShadowRoot | DocumentFragment,
styleId: string = 'esengine-node-editor-styles'
): HTMLStyleElement | null {
// Check if already injected
const existingStyle = (root as any).getElementById?.(styleId) ||
(root as any).querySelector?.(`#${styleId}`);
if (existingStyle) {
return existingStyle as HTMLStyleElement;
}
// Create and inject style element
const style = document.createElement('style');
style.id = styleId;
style.textContent = nodeEditorCssText;
if ('head' in root) {
// Document
(root as Document).head.appendChild(style);
} else {
// ShadowRoot or DocumentFragment
root.appendChild(style);
}
return style;
}

View File

@@ -6,6 +6,7 @@
@import './variables.css';
@import './Canvas.css';
@import './GraphNode.css';
@import './GroupNode.css';
@import './NodePin.css';
@import './Connection.css';
@import './ContextMenu.css';

View File

@@ -4,12 +4,14 @@ import dts from 'vite-plugin-dts';
import react from '@vitejs/plugin-react';
/**
* Custom plugin: Convert CSS to self-executing style injection code
* CSS
* Custom plugin: Handle CSS for node editor
* CSS
*
* This plugin does two things:
* 1. Auto-injects CSS into document.head for normal usage
* 2. Replaces placeholder in cssText.ts with actual CSS for Shadow DOM usage
*/
function injectCSSPlugin(): any {
let cssCounter = 0;
return {
name: 'inject-css-plugin',
enforce: 'post' as const,
@@ -23,19 +25,28 @@ function injectCSSPlugin(): any {
const cssChunk = bundle[cssFile];
if (!cssChunk || !cssChunk.source) continue;
const cssContent = cssChunk.source;
const styleId = `esengine-node-editor-style-${cssCounter++}`;
const cssContent = cssChunk.source as string;
const styleId = 'esengine-node-editor-styles';
// Generate style injection code (生成样式注入代码)
const injectCode = `(function(){if(typeof document!=='undefined'){var s=document.createElement('style');s.id='${styleId}';if(!document.getElementById(s.id)){s.textContent=${JSON.stringify(cssContent)};document.head.appendChild(s);}}})();`;
// Inject into index.js (注入到 index.js)
// Process all JS bundles (处理所有 JS 包)
for (const jsKey of bundleKeys) {
if (!jsKey.endsWith('.js')) continue;
if (!jsKey.endsWith('.js') && !jsKey.endsWith('.cjs')) continue;
const jsChunk = bundle[jsKey];
if (!jsChunk || jsChunk.type !== 'chunk' || !jsChunk.code) continue;
if (jsKey === 'index.js') {
// Replace CSS placeholder with actual CSS content
// 将 CSS 占位符替换为实际的 CSS 内容
// Match both single and double quotes (ESM uses single, CJS uses double)
jsChunk.code = jsChunk.code.replace(
/['"]__NODE_EDITOR_CSS_PLACEHOLDER__['"]/g,
JSON.stringify(cssContent)
);
// Auto-inject CSS for index bundles (为 index 包自动注入 CSS)
if (jsKey === 'index.js' || jsKey === 'index.cjs') {
jsChunk.code = injectCode + '\n' + jsChunk.code;
}
}
@@ -65,8 +76,11 @@ export default defineConfig({
entry: {
index: resolve(__dirname, 'src/index.ts')
},
formats: ['es'],
fileName: (format, entryName) => `${entryName}.js`
formats: ['es', 'cjs'],
fileName: (format, entryName) => {
if (format === 'cjs') return `${entryName}.cjs`;
return `${entryName}.js`;
}
},
rollupOptions: {
external: [

View File

@@ -1,47 +0,0 @@
/**
* 清理开发服务器进程
* 用于 Windows 平台自动清理残留的 Vite 进程
*/
import { execSync } from 'child_process';
const PORT = 5173;
try {
console.log(`正在查找占用端口 ${PORT} 的进程...`);
// Windows 命令
const result = execSync(`netstat -ano | findstr :${PORT}`, { encoding: 'utf8' });
// 解析 PID
const lines = result.split('\n');
const pids = new Set();
for (const line of lines) {
if (line.includes('LISTENING')) {
const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1];
if (pid && pid !== '0') {
pids.add(pid);
}
}
}
if (pids.size === 0) {
console.log(`✓ 端口 ${PORT} 未被占用`);
} else {
console.log(`发现 ${pids.size} 个进程占用端口 ${PORT}`);
for (const pid of pids) {
try {
// Windows 需要使用 /F /PID 而不是 //F //PID
execSync(`taskkill /F /PID ${pid}`, { encoding: 'utf8', stdio: 'ignore' });
console.log(`✓ 已终止进程 PID: ${pid}`);
} catch (e) {
console.log(`✗ 无法终止进程 PID: ${pid}`);
}
}
}
} catch (error) {
// 如果 netstat 没有找到结果,会抛出错误,这是正常的
console.log(`✓ 端口 ${PORT} 未被占用`);
}

View File

@@ -1,5 +1,80 @@
# @esengine/blueprint
## 4.4.0
### Minor Changes
- [#438](https://github.com/esengine/esengine/pull/438) [`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b) Thanks [@esengine](https://github.com/esengine)! - feat(node-editor): add visual group box for organizing nodes
- Add NodeGroup model with dynamic bounds calculation based on node pin counts
- Add GroupNodeComponent for rendering group boxes behind nodes
- Groups automatically resize to wrap contained nodes
- Dragging group header moves all nodes inside together
- Support group serialization/deserialization
- Export `estimateNodeHeight` and `NodeBounds` for accurate size calculation
feat(blueprint): add comprehensive math and logic nodes
Math nodes:
- Modulo, Abs, Min, Max, Power, Sqrt
- Floor, Ceil, Round, Sign, Negate
- Sin, Cos, Tan, Asin, Acos, Atan, Atan2
- DegToRad, RadToDeg, Lerp, InverseLerp
- Clamp, Wrap, RandomRange, RandomInt
Logic nodes:
- Equal, NotEqual, GreaterThan, GreaterThanOrEqual
- LessThan, LessThanOrEqual, InRange
- AND, OR, NOT, XOR, NAND
- IsNull, Select (ternary)
## 4.3.0
### Minor Changes
- [#435](https://github.com/esengine/esengine/pull/435) [`c2acd14`](https://github.com/esengine/esengine/commit/c2acd14fce83af6cd116b3f2e40607229ccc3d6e) Thanks [@esengine](https://github.com/esengine)! - feat(blueprint): 添加 Add Component 节点支持 + 变量节点 + ECS 模式重构
新功能:
- 为每个 @BlueprintExpose 组件自动生成 Add_ComponentName 节点
- Add 节点支持设置初始属性值
- 添加通用 ECS_AddComponent 节点用于动态添加组件
- @BlueprintExpose 装饰的组件自动注册,无需手动调用 registerComponentClass()
- 添加变量节点GetVariable, SetVariable, GetBoolVariable, GetFloatVariable, GetIntVariable, GetStringVariable
重构:
- BlueprintComponent 使用 @ECSComponent 装饰器注册
- BlueprintSystem 继承标准 System 基类
- 简化组件 API优化 VM 生命周期管理
- ExecutionContext.getComponentClass() 自动查找 @BlueprintExpose 注册的组件
## 4.2.0
### Minor Changes
- [#433](https://github.com/esengine/esengine/pull/433) [`2e84942`](https://github.com/esengine/esengine/commit/2e84942ea14c5326620398add05840fa8bea16f8) Thanks [@esengine](https://github.com/esengine)! - feat(blueprint): 添加 Add Component 节点支持 + ECS 模式重构
新功能:
- 为每个 @BlueprintExpose 组件自动生成 Add_ComponentName 节点
- Add 节点支持设置初始属性值
- 添加通用 ECS_AddComponent 节点用于动态添加组件
- 添加 registerComponentClass() 用于手动注册组件类
重构:
- BlueprintComponent 使用 @ECSComponent 装饰器注册
- BlueprintSystem 继承标准 System 基类
- 简化组件 API优化 VM 生命周期管理
## 4.1.0
### Minor Changes
- [#430](https://github.com/esengine/esengine/pull/430) [`caf3be7`](https://github.com/esengine/esengine/commit/caf3be72cdcc730492c63abe5f1715893f3579ac) Thanks [@esengine](https://github.com/esengine)! - feat(blueprint): 重构装饰器系统,移除 Reflect 依赖 | Refactor decorator system, remove Reflect dependency
**@esengine/blueprint**
- 移除 `Reflect.getMetadata` 依赖,装饰器现在要求显式指定类型 | Removed `Reflect.getMetadata` dependency, decorators now require explicit type specification
- 简化 `BlueprintProperty``BlueprintMethod` 装饰器的元数据结构 | Simplified metadata structure for `BlueprintProperty` and `BlueprintMethod` decorators
- 新增 `inferPinType` 工具函数用于类型推断 | Added `inferPinType` utility function for type inference
- 优化组件节点生成器以适配新的元数据结构 | Optimized component node generator for new metadata structure
## 4.0.1
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/blueprint",
"version": "4.0.1",
"version": "4.4.0",
"description": "Visual scripting system - works with any ECS framework (ESEngine, Cocos, Laya, etc.)",
"main": "dist/index.js",
"module": "dist/index.js",

View File

@@ -1,67 +0,0 @@
/**
* @zh ESEngine 蓝图插件
* @en ESEngine Blueprint Plugin
*
* @zh 此文件包含与 ESEngine 引擎核心集成的代码。
* 使用 Cocos/Laya 等其他引擎时不需要此文件。
*
* @en This file contains code for integrating with ESEngine engine-core.
* Not needed when using other engines like Cocos/Laya.
*/
import type { IRuntimePlugin, ModuleManifest, IRuntimeModule } from '@esengine/engine-core';
/**
* @zh 蓝图运行时模块
* @en Blueprint Runtime Module
*
* @zh 注意:蓝图使用自定义系统 (IBlueprintSystem) 而非 EntitySystem
* 因此这里不实现 createSystems。蓝图系统应使用 createBlueprintSystem(scene) 手动创建。
*
* @en Note: Blueprint uses a custom system (IBlueprintSystem) instead of EntitySystem,
* so createSystems is not implemented here. Blueprint systems should be created
* manually using createBlueprintSystem(scene).
*/
class BlueprintRuntimeModule implements IRuntimeModule {
async onInitialize(): Promise<void> {
// Blueprint system initialization
}
onDestroy(): void {
// Cleanup
}
}
/**
* @zh 蓝图的插件清单
* @en Plugin manifest for Blueprint
*/
const manifest: ModuleManifest = {
id: 'blueprint',
name: '@esengine/blueprint',
displayName: 'Blueprint',
version: '1.0.0',
description: '可视化脚本系统',
category: 'AI',
icon: 'Workflow',
isCore: false,
defaultEnabled: false,
isEngineModule: true,
dependencies: ['core'],
exports: {
components: ['BlueprintComponent'],
systems: ['BlueprintSystem']
},
requiresWasm: false
};
/**
* @zh 蓝图插件
* @en Blueprint Plugin
*/
export const BlueprintPlugin: IRuntimePlugin = {
manifest,
runtimeModule: new BlueprintRuntimeModule()
};
export { BlueprintRuntimeModule };

View File

@@ -1,37 +0,0 @@
/**
* @zh ESEngine 集成入口
* @en ESEngine integration entry point
*
* @zh 此模块包含与 ESEngine 引擎核心集成所需的所有代码。
* 使用 Cocos/Laya 等其他引擎时,只需导入主模块即可。
*
* @en This module contains all code required for ESEngine engine-core integration.
* When using other engines like Cocos/Laya, just import the main module.
*
* @example ESEngine 使用方式 / ESEngine usage:
* ```typescript
* import { BlueprintPlugin } from '@esengine/blueprint/esengine';
*
* // Register with ESEngine plugin system
* engine.registerPlugin(BlueprintPlugin);
* ```
*
* @example Cocos/Laya 使用方式 / Cocos/Laya usage:
* ```typescript
* import {
* createBlueprintSystem,
* createBlueprintComponentData
* } from '@esengine/blueprint';
*
* // Create blueprint system for your scene
* const blueprintSystem = createBlueprintSystem(scene);
*
* // Add to your game loop
* function update(dt) {
* blueprintSystem.process(blueprintEntities, dt);
* }
* ```
*/
// Runtime module and plugin
export { BlueprintPlugin, BlueprintRuntimeModule } from './BlueprintPlugin';

View File

@@ -1,32 +1,51 @@
/**
* @esengine/blueprint - Visual scripting system for ECS Framework
*
* @zh 蓝图可视化脚本系统 - 可与任何 ECS 框架配合使用
* @en Visual scripting system - works with any ECS framework
* @zh 蓝图可视化脚本系统 - ECS 框架深度集成
* @en Visual scripting system - Deep integration with ECS framework
*
* @zh 此包是通用的可视化脚本实现,可以与任何 ECS 框架配合使用。
* 对于 ESEngine 集成,请从 '@esengine/blueprint/esengine' 导入插件。
* @zh 此包提供完整的可视化脚本功能:
* - 内置 ECS 操作节点Entity、Component、Flow
* - 组件自动节点生成(使用装饰器标记)
* - 运行时蓝图执行
*
* @en This package is a generic visual scripting implementation that works with any ECS framework.
* For ESEngine integration, import the plugin from '@esengine/blueprint/esengine'.
* @en This package provides complete visual scripting features:
* - Built-in ECS operation nodes (Entity, Component, Flow)
* - Auto component node generation (using decorators)
* - Runtime blueprint execution
*
* @example Cocos/Laya/通用 ECS 使用方式:
* @example 基础使用 | Basic Usage:
* ```typescript
* import {
* createBlueprintSystem,
* createBlueprintComponentData
* } from '@esengine/blueprint';
* import { BlueprintSystem, BlueprintComponent } from '@esengine/blueprint';
* import { Scene, Core } from '@esengine/ecs-framework';
*
* // Create blueprint system for your scene
* const blueprintSystem = createBlueprintSystem(scene);
* // 创建场景并添加蓝图系统
* const scene = new Scene();
* scene.addSystem(new BlueprintSystem());
* Core.setScene(scene);
*
* // Create component data
* const componentData = createBlueprintComponentData();
* componentData.blueprintAsset = loadedAsset;
* // 为实体添加蓝图
* const entity = scene.createEntity('Player');
* const blueprint = new BlueprintComponent();
* blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
* entity.addComponent(blueprint);
* ```
*
* // Add to your game loop
* function update(dt) {
* blueprintSystem.process(blueprintEntities, dt);
* @example 标记组件 | Mark Components:
* ```typescript
* import { BlueprintExpose, BlueprintProperty, BlueprintMethod } from '@esengine/blueprint';
* import { Component, ECSComponent } from '@esengine/ecs-framework';
*
* @ECSComponent('Health')
* @BlueprintExpose({ displayName: '生命值' })
* export class HealthComponent extends Component {
* @BlueprintProperty({ displayName: '当前生命值' })
* current: number = 100;
*
* @BlueprintMethod({ displayName: '治疗' })
* heal(amount: number): void {
* this.current += amount;
* }
* }
* ```
*
@@ -45,23 +64,45 @@ export * from './triggers';
// Composition
export * from './composition';
// Nodes (import to register)
// Registry (decorators & auto-generation)
export * from './registry';
// Nodes (import to register built-in nodes)
import './nodes';
// Re-export commonly used items
export { NodeRegistry, RegisterNode } from './runtime/NodeRegistry';
export { BlueprintVM } from './runtime/BlueprintVM';
export {
createBlueprintComponentData,
initializeBlueprintVM,
startBlueprint,
stopBlueprint,
tickBlueprint,
cleanupBlueprint
} from './runtime/BlueprintComponent';
export {
createBlueprintSystem,
triggerBlueprintEvent,
triggerCustomBlueprintEvent
} from './runtime/BlueprintSystem';
export { BlueprintComponent } from './runtime/BlueprintComponent';
export { BlueprintSystem } from './runtime/BlueprintSystem';
export { ExecutionContext } from './runtime/ExecutionContext';
export { createEmptyBlueprint, validateBlueprintAsset } from './types/blueprint';
// Component registration helper
import { ExecutionContext } from './runtime/ExecutionContext';
import type { Component } from '@esengine/ecs-framework';
/**
* @zh 注册组件类以支持在蓝图中动态创建
* @en Register a component class for dynamic creation in blueprints
*
* @example
* ```typescript
* import { registerComponentClass } from '@esengine/blueprint';
* import { MyComponent } from './MyComponent';
*
* registerComponentClass('MyComponent', MyComponent);
* ```
*/
export function registerComponentClass(typeName: string, componentClass: new () => Component): void {
ExecutionContext.registerComponentClass(typeName, componentClass);
}
// Re-export registry for convenience
export {
BlueprintExpose,
BlueprintProperty,
BlueprintMethod,
registerAllComponentNodes,
registerComponentNodes
} from './registry';

View File

@@ -0,0 +1,416 @@
/**
* @zh ECS 组件操作节点
* @en ECS Component Operation Nodes
*
* @zh 提供蓝图中对 ECS 组件的完整操作支持
* @en Provides complete ECS component operations in blueprint
*/
import type { Entity, Component } from '@esengine/ecs-framework';
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
// ============================================================================
// Add Component (Generic) | 添加组件(通用)
// ============================================================================
export const AddComponentTemplate: BlueprintNodeTemplate = {
type: 'ECS_AddComponent',
title: 'Add Component',
category: 'component',
color: '#1e8b8b',
description: 'Adds a component to an entity by type name (按类型名称为实体添加组件)',
keywords: ['component', 'add', 'create', 'attach'],
menuPath: ['ECS', 'Component', 'Add Component'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'entity', type: 'entity', displayName: 'Entity' },
{ name: 'componentType', type: 'string', displayName: 'Component Type', defaultValue: '' }
],
outputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'component', type: 'component', displayName: 'Component' },
{ name: 'success', type: 'bool', displayName: 'Success' }
]
};
@RegisterNode(AddComponentTemplate)
export class AddComponentExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
const componentType = context.evaluateInput(node.id, 'componentType', '') as string;
if (!entity || entity.isDestroyed || !componentType) {
return { outputs: { component: null, success: false }, nextExec: 'exec' };
}
// Check if component already exists
const existing = entity.components.find(c =>
c.constructor.name === componentType ||
(c.constructor as any).__componentName__ === componentType
);
if (existing) {
return { outputs: { component: existing, success: false }, nextExec: 'exec' };
}
// Try to create component from registry
const ComponentClass = context.getComponentClass?.(componentType);
if (!ComponentClass) {
console.warn(`[Blueprint] Component type not found: ${componentType}`);
return { outputs: { component: null, success: false }, nextExec: 'exec' };
}
try {
const component = new ComponentClass();
entity.addComponent(component);
return { outputs: { component, success: true }, nextExec: 'exec' };
} catch (error) {
console.error(`[Blueprint] Failed to add component ${componentType}:`, error);
return { outputs: { component: null, success: false }, nextExec: 'exec' };
}
}
}
// ============================================================================
// Has Component | 是否有组件
// ============================================================================
export const HasComponentTemplate: BlueprintNodeTemplate = {
type: 'ECS_HasComponent',
title: 'Has Component',
category: 'component',
color: '#1e8b8b',
isPure: true,
description: 'Checks if an entity has a component of the specified type (检查实体是否拥有指定类型的组件)',
keywords: ['component', 'has', 'check', 'exists', 'contains'],
menuPath: ['ECS', 'Component', 'Has Component'],
inputs: [
{ name: 'entity', type: 'entity', displayName: 'Entity' },
{ name: 'componentType', type: 'string', displayName: 'Component Type', defaultValue: '' }
],
outputs: [
{ name: 'hasComponent', type: 'bool', displayName: 'Has Component' }
]
};
@RegisterNode(HasComponentTemplate)
export class HasComponentExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
const componentType = context.evaluateInput(node.id, 'componentType', '') as string;
if (!entity || entity.isDestroyed || !componentType) {
return { outputs: { hasComponent: false } };
}
const hasIt = entity.components.some(c =>
c.constructor.name === componentType ||
(c.constructor as any).__componentName__ === componentType
);
return { outputs: { hasComponent: hasIt } };
}
}
// ============================================================================
// Get Component | 获取组件
// ============================================================================
export const GetComponentTemplate: BlueprintNodeTemplate = {
type: 'ECS_GetComponent',
title: 'Get Component',
category: 'component',
color: '#1e8b8b',
isPure: true,
description: 'Gets a component from an entity by type name (按类型名称从实体获取组件)',
keywords: ['component', 'get', 'find', 'access'],
menuPath: ['ECS', 'Component', 'Get Component'],
inputs: [
{ name: 'entity', type: 'entity', displayName: 'Entity' },
{ name: 'componentType', type: 'string', displayName: 'Component Type', defaultValue: '' }
],
outputs: [
{ name: 'component', type: 'component', displayName: 'Component' },
{ name: 'found', type: 'bool', displayName: 'Found' }
]
};
@RegisterNode(GetComponentTemplate)
export class GetComponentExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
const componentType = context.evaluateInput(node.id, 'componentType', '') as string;
if (!entity || entity.isDestroyed || !componentType) {
return { outputs: { component: null, found: false } };
}
const component = entity.components.find(c =>
c.constructor.name === componentType ||
(c.constructor as any).__componentName__ === componentType
);
return {
outputs: {
component: component ?? null,
found: component != null
}
};
}
}
// ============================================================================
// Get All Components | 获取所有组件
// ============================================================================
export const GetAllComponentsTemplate: BlueprintNodeTemplate = {
type: 'ECS_GetAllComponents',
title: 'Get All Components',
category: 'component',
color: '#1e8b8b',
isPure: true,
description: 'Gets all components from an entity (获取实体的所有组件)',
keywords: ['component', 'get', 'all', 'list'],
menuPath: ['ECS', 'Component', 'Get All Components'],
inputs: [
{ name: 'entity', type: 'entity', displayName: 'Entity' }
],
outputs: [
{ name: 'components', type: 'array', displayName: 'Components', arrayType: 'component' },
{ name: 'count', type: 'int', displayName: 'Count' }
]
};
@RegisterNode(GetAllComponentsTemplate)
export class GetAllComponentsExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
if (!entity || entity.isDestroyed) {
return { outputs: { components: [], count: 0 } };
}
const components = [...entity.components];
return {
outputs: {
components,
count: components.length
}
};
}
}
// ============================================================================
// Remove Component | 移除组件
// ============================================================================
export const RemoveComponentTemplate: BlueprintNodeTemplate = {
type: 'ECS_RemoveComponent',
title: 'Remove Component',
category: 'component',
color: '#8b1e1e',
description: 'Removes a component from an entity (从实体移除组件)',
keywords: ['component', 'remove', 'delete', 'destroy'],
menuPath: ['ECS', 'Component', 'Remove Component'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'entity', type: 'entity', displayName: 'Entity' },
{ name: 'componentType', type: 'string', displayName: 'Component Type', defaultValue: '' }
],
outputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'removed', type: 'bool', displayName: 'Removed' }
]
};
@RegisterNode(RemoveComponentTemplate)
export class RemoveComponentExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
const componentType = context.evaluateInput(node.id, 'componentType', '') as string;
if (!entity || entity.isDestroyed || !componentType) {
return { outputs: { removed: false }, nextExec: 'exec' };
}
const component = entity.components.find(c =>
c.constructor.name === componentType ||
(c.constructor as any).__componentName__ === componentType
);
if (component) {
entity.removeComponent(component);
return { outputs: { removed: true }, nextExec: 'exec' };
}
return { outputs: { removed: false }, nextExec: 'exec' };
}
}
// ============================================================================
// Get Component Property | 获取组件属性
// ============================================================================
export const GetComponentPropertyTemplate: BlueprintNodeTemplate = {
type: 'ECS_GetComponentProperty',
title: 'Get Component Property',
category: 'component',
color: '#1e8b8b',
isPure: true,
description: 'Gets a property value from a component (从组件获取属性值)',
keywords: ['component', 'property', 'get', 'value', 'field'],
menuPath: ['ECS', 'Component', 'Get Property'],
inputs: [
{ name: 'component', type: 'component', displayName: 'Component' },
{ name: 'propertyName', type: 'string', displayName: 'Property Name', defaultValue: '' }
],
outputs: [
{ name: 'value', type: 'any', displayName: 'Value' },
{ name: 'found', type: 'bool', displayName: 'Found' }
]
};
@RegisterNode(GetComponentPropertyTemplate)
export class GetComponentPropertyExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const component = context.evaluateInput(node.id, 'component', null) as Component | null;
const propertyName = context.evaluateInput(node.id, 'propertyName', '') as string;
if (!component || !propertyName) {
return { outputs: { value: null, found: false } };
}
if (propertyName in component) {
return {
outputs: {
value: (component as any)[propertyName],
found: true
}
};
}
return { outputs: { value: null, found: false } };
}
}
// ============================================================================
// Set Component Property | 设置组件属性
// ============================================================================
export const SetComponentPropertyTemplate: BlueprintNodeTemplate = {
type: 'ECS_SetComponentProperty',
title: 'Set Component Property',
category: 'component',
color: '#1e8b8b',
description: 'Sets a property value on a component (设置组件的属性值)',
keywords: ['component', 'property', 'set', 'value', 'field', 'modify'],
menuPath: ['ECS', 'Component', 'Set Property'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'component', type: 'component', displayName: 'Component' },
{ name: 'propertyName', type: 'string', displayName: 'Property Name', defaultValue: '' },
{ name: 'value', type: 'any', displayName: 'Value' }
],
outputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'success', type: 'bool', displayName: 'Success' }
]
};
@RegisterNode(SetComponentPropertyTemplate)
export class SetComponentPropertyExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const component = context.evaluateInput(node.id, 'component', null) as Component | null;
const propertyName = context.evaluateInput(node.id, 'propertyName', '') as string;
const value = context.evaluateInput(node.id, 'value', null);
if (!component || !propertyName) {
return { outputs: { success: false }, nextExec: 'exec' };
}
if (propertyName in component) {
(component as any)[propertyName] = value;
return { outputs: { success: true }, nextExec: 'exec' };
}
return { outputs: { success: false }, nextExec: 'exec' };
}
}
// ============================================================================
// Get Component Type Name | 获取组件类型名称
// ============================================================================
export const GetComponentTypeNameTemplate: BlueprintNodeTemplate = {
type: 'ECS_GetComponentTypeName',
title: 'Get Component Type',
category: 'component',
color: '#1e8b8b',
isPure: true,
description: 'Gets the type name of a component (获取组件的类型名称)',
keywords: ['component', 'type', 'name', 'class'],
menuPath: ['ECS', 'Component', 'Get Type Name'],
inputs: [
{ name: 'component', type: 'component', displayName: 'Component' }
],
outputs: [
{ name: 'typeName', type: 'string', displayName: 'Type Name' }
]
};
@RegisterNode(GetComponentTypeNameTemplate)
export class GetComponentTypeNameExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const component = context.evaluateInput(node.id, 'component', null) as Component | null;
if (!component) {
return { outputs: { typeName: '' } };
}
const typeName = (component.constructor as any).__componentName__ ?? component.constructor.name;
return { outputs: { typeName } };
}
}
// ============================================================================
// Get Entity From Component | 从组件获取实体
// ============================================================================
export const GetEntityFromComponentTemplate: BlueprintNodeTemplate = {
type: 'ECS_GetEntityFromComponent',
title: 'Get Owner Entity',
category: 'component',
color: '#1e8b8b',
isPure: true,
description: 'Gets the entity that owns a component (获取拥有组件的实体)',
keywords: ['component', 'entity', 'owner', 'parent'],
menuPath: ['ECS', 'Component', 'Get Owner Entity'],
inputs: [
{ name: 'component', type: 'component', displayName: 'Component' }
],
outputs: [
{ name: 'entity', type: 'entity', displayName: 'Entity' },
{ name: 'found', type: 'bool', displayName: 'Found' }
]
};
@RegisterNode(GetEntityFromComponentTemplate)
export class GetEntityFromComponentExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const component = context.evaluateInput(node.id, 'component', null) as Component | null;
if (!component || component.entityId == null) {
return { outputs: { entity: null, found: false } };
}
const entity = context.scene.findEntityById(component.entityId);
return {
outputs: {
entity: entity ?? null,
found: entity != null
}
};
}
}

View File

@@ -0,0 +1,485 @@
/**
* @zh ECS 实体操作节点
* @en ECS Entity Operation Nodes
*
* @zh 提供蓝图中对 ECS 实体的完整操作支持
* @en Provides complete ECS entity operations in blueprint
*/
import type { Entity } from '@esengine/ecs-framework';
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
// ============================================================================
// Self Entity | 自身实体
// ============================================================================
export const GetSelfTemplate: BlueprintNodeTemplate = {
type: 'ECS_GetSelf',
title: 'Get Self',
category: 'entity',
color: '#1e5a8b',
isPure: true,
description: 'Gets the entity that owns this blueprint (获取拥有此蓝图的实体)',
keywords: ['self', 'this', 'owner', 'entity', 'me'],
menuPath: ['ECS', 'Entity', 'Get Self'],
inputs: [],
outputs: [
{ name: 'entity', type: 'entity', displayName: 'Self' }
]
};
@RegisterNode(GetSelfTemplate)
export class GetSelfExecutor implements INodeExecutor {
execute(_node: BlueprintNode, context: ExecutionContext): ExecutionResult {
return { outputs: { entity: context.entity } };
}
}
// ============================================================================
// Create Entity | 创建实体
// ============================================================================
export const CreateEntityTemplate: BlueprintNodeTemplate = {
type: 'ECS_CreateEntity',
title: 'Create Entity',
category: 'entity',
color: '#1e5a8b',
description: 'Creates a new entity in the scene (在场景中创建新实体)',
keywords: ['entity', 'create', 'spawn', 'new', 'instantiate'],
menuPath: ['ECS', 'Entity', 'Create Entity'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'name', type: 'string', displayName: 'Name', defaultValue: 'NewEntity' }
],
outputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'entity', type: 'entity', displayName: 'Entity' }
]
};
@RegisterNode(CreateEntityTemplate)
export class CreateEntityExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const name = context.evaluateInput(node.id, 'name', 'NewEntity') as string;
const entity = context.scene.createEntity(name);
return { outputs: { entity }, nextExec: 'exec' };
}
}
// ============================================================================
// Destroy Entity | 销毁实体
// ============================================================================
export const DestroyEntityTemplate: BlueprintNodeTemplate = {
type: 'ECS_DestroyEntity',
title: 'Destroy Entity',
category: 'entity',
color: '#8b1e1e',
description: 'Destroys an entity from the scene (从场景中销毁实体)',
keywords: ['entity', 'destroy', 'remove', 'delete', 'kill'],
menuPath: ['ECS', 'Entity', 'Destroy Entity'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'entity', type: 'entity', displayName: 'Entity' }
],
outputs: [
{ name: 'exec', type: 'exec', displayName: '' }
]
};
@RegisterNode(DestroyEntityTemplate)
export class DestroyEntityExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const entity = context.evaluateInput(node.id, 'entity', null) as Entity | null;
if (entity && !entity.isDestroyed) {
entity.destroy();
}
return { nextExec: 'exec' };
}
}
// ============================================================================
// Destroy Self | 销毁自身
// ============================================================================
export const DestroySelfTemplate: BlueprintNodeTemplate = {
type: 'ECS_DestroySelf',
title: 'Destroy Self',
category: 'entity',
color: '#8b1e1e',
description: 'Destroys the entity that owns this blueprint (销毁拥有此蓝图的实体)',
keywords: ['self', 'destroy', 'suicide', 'remove', 'delete'],
menuPath: ['ECS', 'Entity', 'Destroy Self'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' }
],
outputs: []
};
@RegisterNode(DestroySelfTemplate)
export class DestroySelfExecutor implements INodeExecutor {
execute(_node: BlueprintNode, context: ExecutionContext): ExecutionResult {
if (!context.entity.isDestroyed) {
context.entity.destroy();
}
return { nextExec: null };
}
}
// ============================================================================
// Is Valid | 是否有效
// ============================================================================
export const IsValidTemplate: BlueprintNodeTemplate = {
type: 'ECS_IsValid',
title: 'Is Valid',
category: 'entity',
color: '#1e5a8b',
isPure: true,
description: 'Checks if an entity reference is valid and not destroyed (检查实体引用是否有效且未被销毁)',
keywords: ['entity', 'valid', 'null', 'check', 'exists', 'alive'],
menuPath: ['ECS', 'Entity', 'Is Valid'],
inputs: [
{ name: 'entity', type: 'entity', displayName: 'Entity' }
],
outputs: [
{ name: 'isValid', type: 'bool', displayName: 'Is Valid' }
]
};
@RegisterNode(IsValidTemplate)
export class IsValidExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const entity = context.evaluateInput(node.id, 'entity', null) as Entity | null;
const isValid = entity != null && !entity.isDestroyed;
return { outputs: { isValid } };
}
}
// ============================================================================
// Get Entity Name | 获取实体名称
// ============================================================================
export const GetEntityNameTemplate: BlueprintNodeTemplate = {
type: 'ECS_GetEntityName',
title: 'Get Entity Name',
category: 'entity',
color: '#1e5a8b',
isPure: true,
description: 'Gets the name of an entity (获取实体的名称)',
keywords: ['entity', 'name', 'get', 'string'],
menuPath: ['ECS', 'Entity', 'Get Name'],
inputs: [
{ name: 'entity', type: 'entity', displayName: 'Entity' }
],
outputs: [
{ name: 'name', type: 'string', displayName: 'Name' }
]
};
@RegisterNode(GetEntityNameTemplate)
export class GetEntityNameExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
return { outputs: { name: entity?.name ?? '' } };
}
}
// ============================================================================
// Set Entity Name | 设置实体名称
// ============================================================================
export const SetEntityNameTemplate: BlueprintNodeTemplate = {
type: 'ECS_SetEntityName',
title: 'Set Entity Name',
category: 'entity',
color: '#1e5a8b',
description: 'Sets the name of an entity (设置实体的名称)',
keywords: ['entity', 'name', 'set', 'rename'],
menuPath: ['ECS', 'Entity', 'Set Name'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'entity', type: 'entity', displayName: 'Entity' },
{ name: 'name', type: 'string', displayName: 'Name', defaultValue: '' }
],
outputs: [
{ name: 'exec', type: 'exec', displayName: '' }
]
};
@RegisterNode(SetEntityNameTemplate)
export class SetEntityNameExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
const name = context.evaluateInput(node.id, 'name', '') as string;
if (entity && !entity.isDestroyed) {
entity.name = name;
}
return { nextExec: 'exec' };
}
}
// ============================================================================
// Get Entity Tag | 获取实体标签
// ============================================================================
export const GetEntityTagTemplate: BlueprintNodeTemplate = {
type: 'ECS_GetEntityTag',
title: 'Get Entity Tag',
category: 'entity',
color: '#1e5a8b',
isPure: true,
description: 'Gets the tag of an entity (获取实体的标签)',
keywords: ['entity', 'tag', 'get', 'category'],
menuPath: ['ECS', 'Entity', 'Get Tag'],
inputs: [
{ name: 'entity', type: 'entity', displayName: 'Entity' }
],
outputs: [
{ name: 'tag', type: 'int', displayName: 'Tag' }
]
};
@RegisterNode(GetEntityTagTemplate)
export class GetEntityTagExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
return { outputs: { tag: entity?.tag ?? 0 } };
}
}
// ============================================================================
// Set Entity Tag | 设置实体标签
// ============================================================================
export const SetEntityTagTemplate: BlueprintNodeTemplate = {
type: 'ECS_SetEntityTag',
title: 'Set Entity Tag',
category: 'entity',
color: '#1e5a8b',
description: 'Sets the tag of an entity (设置实体的标签)',
keywords: ['entity', 'tag', 'set', 'category'],
menuPath: ['ECS', 'Entity', 'Set Tag'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'entity', type: 'entity', displayName: 'Entity' },
{ name: 'tag', type: 'int', displayName: 'Tag', defaultValue: 0 }
],
outputs: [
{ name: 'exec', type: 'exec', displayName: '' }
]
};
@RegisterNode(SetEntityTagTemplate)
export class SetEntityTagExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
const tag = context.evaluateInput(node.id, 'tag', 0) as number;
if (entity && !entity.isDestroyed) {
entity.tag = tag;
}
return { nextExec: 'exec' };
}
}
// ============================================================================
// Set Entity Active | 设置实体激活状态
// ============================================================================
export const SetEntityActiveTemplate: BlueprintNodeTemplate = {
type: 'ECS_SetEntityActive',
title: 'Set Active',
category: 'entity',
color: '#1e5a8b',
description: 'Sets whether an entity is active (设置实体是否激活)',
keywords: ['entity', 'active', 'enable', 'disable', 'visible'],
menuPath: ['ECS', 'Entity', 'Set Active'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'entity', type: 'entity', displayName: 'Entity' },
{ name: 'active', type: 'bool', displayName: 'Active', defaultValue: true }
],
outputs: [
{ name: 'exec', type: 'exec', displayName: '' }
]
};
@RegisterNode(SetEntityActiveTemplate)
export class SetEntityActiveExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
const active = context.evaluateInput(node.id, 'active', true) as boolean;
if (entity && !entity.isDestroyed) {
entity.active = active;
}
return { nextExec: 'exec' };
}
}
// ============================================================================
// Is Entity Active | 实体是否激活
// ============================================================================
export const IsEntityActiveTemplate: BlueprintNodeTemplate = {
type: 'ECS_IsEntityActive',
title: 'Is Active',
category: 'entity',
color: '#1e5a8b',
isPure: true,
description: 'Checks if an entity is active (检查实体是否激活)',
keywords: ['entity', 'active', 'enabled', 'check'],
menuPath: ['ECS', 'Entity', 'Is Active'],
inputs: [
{ name: 'entity', type: 'entity', displayName: 'Entity' }
],
outputs: [
{ name: 'isActive', type: 'bool', displayName: 'Is Active' }
]
};
@RegisterNode(IsEntityActiveTemplate)
export class IsEntityActiveExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
return { outputs: { isActive: entity?.active ?? false } };
}
}
// ============================================================================
// Find Entity By Name | 按名称查找实体
// ============================================================================
export const FindEntityByNameTemplate: BlueprintNodeTemplate = {
type: 'ECS_FindEntityByName',
title: 'Find Entity By Name',
category: 'entity',
color: '#1e5a8b',
isPure: true,
description: 'Finds an entity by name in the scene (在场景中按名称查找实体)',
keywords: ['entity', 'find', 'name', 'search', 'get', 'lookup'],
menuPath: ['ECS', 'Entity', 'Find By Name'],
inputs: [
{ name: 'name', type: 'string', displayName: 'Name', defaultValue: '' }
],
outputs: [
{ name: 'entity', type: 'entity', displayName: 'Entity' },
{ name: 'found', type: 'bool', displayName: 'Found' }
]
};
@RegisterNode(FindEntityByNameTemplate)
export class FindEntityByNameExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const name = context.evaluateInput(node.id, 'name', '') as string;
const entity = context.scene.findEntity(name);
return {
outputs: {
entity: entity ?? null,
found: entity != null
}
};
}
}
// ============================================================================
// Find Entities By Tag | 按标签查找实体
// ============================================================================
export const FindEntitiesByTagTemplate: BlueprintNodeTemplate = {
type: 'ECS_FindEntitiesByTag',
title: 'Find Entities By Tag',
category: 'entity',
color: '#1e5a8b',
isPure: true,
description: 'Finds all entities with a specific tag (查找所有具有特定标签的实体)',
keywords: ['entity', 'find', 'tag', 'search', 'get', 'all'],
menuPath: ['ECS', 'Entity', 'Find By Tag'],
inputs: [
{ name: 'tag', type: 'int', displayName: 'Tag', defaultValue: 0 }
],
outputs: [
{ name: 'entities', type: 'array', displayName: 'Entities', arrayType: 'entity' },
{ name: 'count', type: 'int', displayName: 'Count' }
]
};
@RegisterNode(FindEntitiesByTagTemplate)
export class FindEntitiesByTagExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const tag = context.evaluateInput(node.id, 'tag', 0) as number;
const entities = context.scene.findEntitiesByTag(tag);
return {
outputs: {
entities,
count: entities.length
}
};
}
}
// ============================================================================
// Get Entity ID | 获取实体 ID
// ============================================================================
export const GetEntityIdTemplate: BlueprintNodeTemplate = {
type: 'ECS_GetEntityId',
title: 'Get Entity ID',
category: 'entity',
color: '#1e5a8b',
isPure: true,
description: 'Gets the unique ID of an entity (获取实体的唯一ID)',
keywords: ['entity', 'id', 'identifier', 'unique'],
menuPath: ['ECS', 'Entity', 'Get ID'],
inputs: [
{ name: 'entity', type: 'entity', displayName: 'Entity' }
],
outputs: [
{ name: 'id', type: 'int', displayName: 'ID' }
]
};
@RegisterNode(GetEntityIdTemplate)
export class GetEntityIdExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
return { outputs: { id: entity?.id ?? -1 } };
}
}
// ============================================================================
// Find Entity By ID | 按 ID 查找实体
// ============================================================================
export const FindEntityByIdTemplate: BlueprintNodeTemplate = {
type: 'ECS_FindEntityById',
title: 'Find Entity By ID',
category: 'entity',
color: '#1e5a8b',
isPure: true,
description: 'Finds an entity by its unique ID (通过唯一ID查找实体)',
keywords: ['entity', 'find', 'id', 'identifier'],
menuPath: ['ECS', 'Entity', 'Find By ID'],
inputs: [
{ name: 'id', type: 'int', displayName: 'ID', defaultValue: 0 }
],
outputs: [
{ name: 'entity', type: 'entity', displayName: 'Entity' },
{ name: 'found', type: 'bool', displayName: 'Found' }
]
};
@RegisterNode(FindEntityByIdTemplate)
export class FindEntityByIdExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const id = context.evaluateInput(node.id, 'id', 0) as number;
const entity = context.scene.findEntityById(id);
return {
outputs: {
entity: entity ?? null,
found: entity != null
}
};
}
}

View File

@@ -0,0 +1,301 @@
/**
* @zh 流程控制节点
* @en Flow Control Nodes
*
* @zh 提供蓝图中的流程控制支持(分支、循环等)
* @en Provides flow control in blueprint (branch, loop, etc.)
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
// ============================================================================
// Branch | 分支
// ============================================================================
export const BranchTemplate: BlueprintNodeTemplate = {
type: 'Flow_Branch',
title: 'Branch',
category: 'flow',
color: '#4a4a4a',
description: 'Executes one of two paths based on a condition (根据条件执行两条路径之一)',
keywords: ['if', 'branch', 'condition', 'switch', 'else'],
menuPath: ['Flow', 'Branch'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'condition', type: 'bool', displayName: 'Condition', defaultValue: false }
],
outputs: [
{ name: 'true', type: 'exec', displayName: 'True' },
{ name: 'false', type: 'exec', displayName: 'False' }
]
};
@RegisterNode(BranchTemplate)
export class BranchExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const condition = context.evaluateInput(node.id, 'condition', false) as boolean;
return { nextExec: condition ? 'true' : 'false' };
}
}
// ============================================================================
// Sequence | 序列
// ============================================================================
export const SequenceTemplate: BlueprintNodeTemplate = {
type: 'Flow_Sequence',
title: 'Sequence',
category: 'flow',
color: '#4a4a4a',
description: 'Executes multiple outputs in order (按顺序执行多个输出)',
keywords: ['sequence', 'order', 'serial', 'chain'],
menuPath: ['Flow', 'Sequence'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' }
],
outputs: [
{ name: 'then0', type: 'exec', displayName: 'Then 0' },
{ name: 'then1', type: 'exec', displayName: 'Then 1' },
{ name: 'then2', type: 'exec', displayName: 'Then 2' },
{ name: 'then3', type: 'exec', displayName: 'Then 3' }
]
};
@RegisterNode(SequenceTemplate)
export class SequenceExecutor implements INodeExecutor {
private currentIndex = 0;
execute(_node: BlueprintNode, _context: ExecutionContext): ExecutionResult {
const outputs = ['then0', 'then1', 'then2', 'then3'];
const nextPin = outputs[this.currentIndex];
this.currentIndex = (this.currentIndex + 1) % outputs.length;
if (this.currentIndex === 0) {
return { nextExec: null };
}
return { nextExec: nextPin };
}
}
// ============================================================================
// Do Once | 只执行一次
// ============================================================================
export const DoOnceTemplate: BlueprintNodeTemplate = {
type: 'Flow_DoOnce',
title: 'Do Once',
category: 'flow',
color: '#4a4a4a',
description: 'Executes the output only once, subsequent calls are ignored (只执行一次,后续调用被忽略)',
keywords: ['once', 'single', 'first', 'one'],
menuPath: ['Flow', 'Do Once'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'reset', type: 'exec', displayName: 'Reset' }
],
outputs: [
{ name: 'exec', type: 'exec', displayName: '' }
]
};
@RegisterNode(DoOnceTemplate)
export class DoOnceExecutor implements INodeExecutor {
private executed = false;
execute(node: BlueprintNode, _context: ExecutionContext): ExecutionResult {
const inputPin = node.data._lastInputPin as string | undefined;
if (inputPin === 'reset') {
this.executed = false;
return { nextExec: null };
}
if (this.executed) {
return { nextExec: null };
}
this.executed = true;
return { nextExec: 'exec' };
}
}
// ============================================================================
// Flip Flop | 触发器
// ============================================================================
export const FlipFlopTemplate: BlueprintNodeTemplate = {
type: 'Flow_FlipFlop',
title: 'Flip Flop',
category: 'flow',
color: '#4a4a4a',
description: 'Alternates between two outputs on each execution (每次执行时在两个输出之间交替)',
keywords: ['flip', 'flop', 'toggle', 'alternate', 'switch'],
menuPath: ['Flow', 'Flip Flop'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' }
],
outputs: [
{ name: 'a', type: 'exec', displayName: 'A' },
{ name: 'b', type: 'exec', displayName: 'B' },
{ name: 'isA', type: 'bool', displayName: 'Is A' }
]
};
@RegisterNode(FlipFlopTemplate)
export class FlipFlopExecutor implements INodeExecutor {
private isA = true;
execute(_node: BlueprintNode, _context: ExecutionContext): ExecutionResult {
const currentIsA = this.isA;
this.isA = !this.isA;
return {
outputs: { isA: currentIsA },
nextExec: currentIsA ? 'a' : 'b'
};
}
}
// ============================================================================
// Gate | 门
// ============================================================================
export const GateTemplate: BlueprintNodeTemplate = {
type: 'Flow_Gate',
title: 'Gate',
category: 'flow',
color: '#4a4a4a',
description: 'Controls execution flow with open/close state (通过开/关状态控制执行流)',
keywords: ['gate', 'open', 'close', 'block', 'allow'],
menuPath: ['Flow', 'Gate'],
inputs: [
{ name: 'exec', type: 'exec', displayName: 'Enter' },
{ name: 'open', type: 'exec', displayName: 'Open' },
{ name: 'close', type: 'exec', displayName: 'Close' },
{ name: 'toggle', type: 'exec', displayName: 'Toggle' },
{ name: 'startOpen', type: 'bool', displayName: 'Start Open', defaultValue: true }
],
outputs: [
{ name: 'exec', type: 'exec', displayName: 'Exit' }
]
};
@RegisterNode(GateTemplate)
export class GateExecutor implements INodeExecutor {
private isOpen: boolean | null = null;
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
if (this.isOpen === null) {
this.isOpen = context.evaluateInput(node.id, 'startOpen', true) as boolean;
}
const inputPin = node.data._lastInputPin as string | undefined;
switch (inputPin) {
case 'open':
this.isOpen = true;
return { nextExec: null };
case 'close':
this.isOpen = false;
return { nextExec: null };
case 'toggle':
this.isOpen = !this.isOpen;
return { nextExec: null };
default:
return { nextExec: this.isOpen ? 'exec' : null };
}
}
}
// ============================================================================
// For Loop | For 循环
// ============================================================================
export const ForLoopTemplate: BlueprintNodeTemplate = {
type: 'Flow_ForLoop',
title: 'For Loop',
category: 'flow',
color: '#4a4a4a',
description: 'Executes the loop body for each index in range (对范围内的每个索引执行循环体)',
keywords: ['for', 'loop', 'iterate', 'repeat', 'count'],
menuPath: ['Flow', 'For Loop'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'start', type: 'int', displayName: 'Start', defaultValue: 0 },
{ name: 'end', type: 'int', displayName: 'End', defaultValue: 10 }
],
outputs: [
{ name: 'loopBody', type: 'exec', displayName: 'Loop Body' },
{ name: 'completed', type: 'exec', displayName: 'Completed' },
{ name: 'index', type: 'int', displayName: 'Index' }
]
};
@RegisterNode(ForLoopTemplate)
export class ForLoopExecutor implements INodeExecutor {
private currentIndex = 0;
private endIndex = 0;
private isRunning = false;
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
if (!this.isRunning) {
this.currentIndex = context.evaluateInput(node.id, 'start', 0) as number;
this.endIndex = context.evaluateInput(node.id, 'end', 10) as number;
this.isRunning = true;
}
if (this.currentIndex < this.endIndex) {
const index = this.currentIndex;
this.currentIndex++;
return {
outputs: { index },
nextExec: 'loopBody'
};
}
this.isRunning = false;
return {
outputs: { index: this.endIndex },
nextExec: 'completed'
};
}
}
// ============================================================================
// While Loop | While 循环
// ============================================================================
export const WhileLoopTemplate: BlueprintNodeTemplate = {
type: 'Flow_WhileLoop',
title: 'While Loop',
category: 'flow',
color: '#4a4a4a',
description: 'Executes the loop body while condition is true (当条件为真时执行循环体)',
keywords: ['while', 'loop', 'repeat', 'condition'],
menuPath: ['Flow', 'While Loop'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'condition', type: 'bool', displayName: 'Condition', defaultValue: true }
],
outputs: [
{ name: 'loopBody', type: 'exec', displayName: 'Loop Body' },
{ name: 'completed', type: 'exec', displayName: 'Completed' }
]
};
@RegisterNode(WhileLoopTemplate)
export class WhileLoopExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const condition = context.evaluateInput(node.id, 'condition', true) as boolean;
if (condition) {
return { nextExec: 'loopBody' };
}
return { nextExec: 'completed' };
}
}

View File

@@ -0,0 +1,16 @@
/**
* @zh ECS 核心节点
* @en ECS Core Nodes
*
* @zh 提供与 ECS 框架交互的蓝图节点
* @en Provides blueprint nodes for ECS framework interaction
*/
// Entity operations | 实体操作
export * from './EntityNodes';
// Component operations | 组件操作
export * from './ComponentNodes';
// Flow control | 流程控制
export * from './FlowNodes';

View File

@@ -17,13 +17,19 @@ export const EventBeginPlayTemplate: BlueprintNodeTemplate = {
category: 'event',
color: '#CC0000',
description: 'Triggered once when the blueprint starts executing (蓝图开始执行时触发一次)',
keywords: ['start', 'begin', 'init', 'event'],
keywords: ['start', 'begin', 'init', 'event', 'self'],
menuPath: ['Events', 'Begin Play'],
inputs: [],
outputs: [
{
name: 'exec',
type: 'exec',
displayName: ''
},
{
name: 'self',
type: 'entity',
displayName: 'Self'
}
]
};
@@ -34,11 +40,12 @@ export const EventBeginPlayTemplate: BlueprintNodeTemplate = {
*/
@RegisterNode(EventBeginPlayTemplate)
export class EventBeginPlayExecutor implements INodeExecutor {
execute(_node: BlueprintNode, _context: ExecutionContext): ExecutionResult {
// Event nodes just trigger execution flow
// 事件节点只触发执行流
execute(_node: BlueprintNode, context: ExecutionContext): ExecutionResult {
return {
nextExec: 'exec'
nextExec: 'exec',
outputs: {
self: context.entity
}
};
}
}

View File

@@ -1,118 +0,0 @@
/**
* @zh 碰撞事件节点 - 碰撞发生时触发
* @en Event Collision Node - Triggered on collision events
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
/**
* @zh EventCollisionEnter 节点模板
* @en EventCollisionEnter node template
*/
export const EventCollisionEnterTemplate: BlueprintNodeTemplate = {
type: 'EventCollisionEnter',
title: 'Event Collision Enter',
category: 'event',
color: '#CC0000',
description: 'Triggered when collision starts / 碰撞开始时触发',
keywords: ['collision', 'enter', 'hit', 'overlap', 'event'],
menuPath: ['Event', 'Collision', 'Enter'],
inputs: [],
outputs: [
{
name: 'exec',
type: 'exec',
displayName: ''
},
{
name: 'otherEntityId',
type: 'string',
displayName: 'Other Entity'
},
{
name: 'pointX',
type: 'float',
displayName: 'Point X'
},
{
name: 'pointY',
type: 'float',
displayName: 'Point Y'
},
{
name: 'normalX',
type: 'float',
displayName: 'Normal X'
},
{
name: 'normalY',
type: 'float',
displayName: 'Normal Y'
}
]
};
/**
* @zh EventCollisionEnter 节点执行器
* @en EventCollisionEnter node executor
*/
@RegisterNode(EventCollisionEnterTemplate)
export class EventCollisionEnterExecutor implements INodeExecutor {
execute(_node: BlueprintNode): ExecutionResult {
return {
nextExec: 'exec',
outputs: {
otherEntityId: '',
pointX: 0,
pointY: 0,
normalX: 0,
normalY: 0
}
};
}
}
/**
* @zh EventCollisionExit 节点模板
* @en EventCollisionExit node template
*/
export const EventCollisionExitTemplate: BlueprintNodeTemplate = {
type: 'EventCollisionExit',
title: 'Event Collision Exit',
category: 'event',
color: '#CC0000',
description: 'Triggered when collision ends / 碰撞结束时触发',
keywords: ['collision', 'exit', 'end', 'separate', 'event'],
menuPath: ['Event', 'Collision', 'Exit'],
inputs: [],
outputs: [
{
name: 'exec',
type: 'exec',
displayName: ''
},
{
name: 'otherEntityId',
type: 'string',
displayName: 'Other Entity'
}
]
};
/**
* @zh EventCollisionExit 节点执行器
* @en EventCollisionExit node executor
*/
@RegisterNode(EventCollisionExitTemplate)
export class EventCollisionExitExecutor implements INodeExecutor {
execute(_node: BlueprintNode): ExecutionResult {
return {
nextExec: 'exec',
outputs: {
otherEntityId: ''
}
};
}
}

View File

@@ -17,13 +17,19 @@ export const EventEndPlayTemplate: BlueprintNodeTemplate = {
category: 'event',
color: '#CC0000',
description: 'Triggered once when the blueprint stops executing (蓝图停止执行时触发一次)',
keywords: ['stop', 'end', 'destroy', 'event'],
keywords: ['stop', 'end', 'destroy', 'event', 'self'],
menuPath: ['Events', 'End Play'],
inputs: [],
outputs: [
{
name: 'exec',
type: 'exec',
displayName: ''
},
{
name: 'self',
type: 'entity',
displayName: 'Self'
}
]
};
@@ -34,9 +40,12 @@ export const EventEndPlayTemplate: BlueprintNodeTemplate = {
*/
@RegisterNode(EventEndPlayTemplate)
export class EventEndPlayExecutor implements INodeExecutor {
execute(_node: BlueprintNode, _context: ExecutionContext): ExecutionResult {
execute(_node: BlueprintNode, context: ExecutionContext): ExecutionResult {
return {
nextExec: 'exec'
nextExec: 'exec',
outputs: {
self: context.entity
}
};
}
}

View File

@@ -1,79 +0,0 @@
/**
* @zh 输入事件节点 - 输入触发时触发
* @en Event Input Node - Triggered on input events
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
/**
* @zh EventInput 节点模板
* @en EventInput node template
*/
export const EventInputTemplate: BlueprintNodeTemplate = {
type: 'EventInput',
title: 'Event Input',
category: 'event',
color: '#CC0000',
description: 'Triggered when input action occurs / 输入动作发生时触发',
keywords: ['input', 'key', 'button', 'action', 'event'],
menuPath: ['Event', 'Input'],
inputs: [
{
name: 'action',
type: 'string',
displayName: 'Action',
defaultValue: ''
}
],
outputs: [
{
name: 'exec',
type: 'exec',
displayName: ''
},
{
name: 'action',
type: 'string',
displayName: 'Action'
},
{
name: 'value',
type: 'float',
displayName: 'Value'
},
{
name: 'pressed',
type: 'bool',
displayName: 'Pressed'
},
{
name: 'released',
type: 'bool',
displayName: 'Released'
}
]
};
/**
* @zh EventInput 节点执行器
* @en EventInput node executor
*
* @zh 注意:事件节点的输出由 VM 在触发时通过 setOutputs 设置
* @en Note: Event node outputs are set by VM via setOutputs when triggered
*/
@RegisterNode(EventInputTemplate)
export class EventInputExecutor implements INodeExecutor {
execute(node: BlueprintNode, _context: ExecutionContext): ExecutionResult {
return {
nextExec: 'exec',
outputs: {
action: node.data?.action ?? '',
value: 0,
pressed: false,
released: false
}
};
}
}

View File

@@ -1,70 +0,0 @@
/**
* @zh 消息事件节点 - 接收消息时触发
* @en Event Message Node - Triggered when message is received
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
/**
* @zh EventMessage 节点模板
* @en EventMessage node template
*/
export const EventMessageTemplate: BlueprintNodeTemplate = {
type: 'EventMessage',
title: 'Event Message',
category: 'event',
color: '#CC0000',
description: 'Triggered when a message is received / 接收到消息时触发',
keywords: ['message', 'receive', 'broadcast', 'event', 'signal'],
menuPath: ['Event', 'Message'],
inputs: [
{
name: 'messageName',
type: 'string',
displayName: 'Message Name',
defaultValue: ''
}
],
outputs: [
{
name: 'exec',
type: 'exec',
displayName: ''
},
{
name: 'messageName',
type: 'string',
displayName: 'Message'
},
{
name: 'senderId',
type: 'string',
displayName: 'Sender ID'
},
{
name: 'payload',
type: 'any',
displayName: 'Payload'
}
]
};
/**
* @zh EventMessage 节点执行器
* @en EventMessage node executor
*/
@RegisterNode(EventMessageTemplate)
export class EventMessageExecutor implements INodeExecutor {
execute(node: BlueprintNode): ExecutionResult {
return {
nextExec: 'exec',
outputs: {
messageName: node.data?.messageName ?? '',
senderId: '',
payload: null
}
};
}
}

View File

@@ -1,132 +0,0 @@
/**
* @zh 状态事件节点 - 状态机状态变化时触发
* @en Event State Node - Triggered on state machine state changes
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
/**
* @zh EventStateEnter 节点模板
* @en EventStateEnter node template
*/
export const EventStateEnterTemplate: BlueprintNodeTemplate = {
type: 'EventStateEnter',
title: 'Event State Enter',
category: 'event',
color: '#CC0000',
description: 'Triggered when entering a state / 进入状态时触发',
keywords: ['state', 'enter', 'fsm', 'machine', 'event'],
menuPath: ['Event', 'State', 'Enter'],
inputs: [
{
name: 'stateName',
type: 'string',
displayName: 'State Name',
defaultValue: ''
}
],
outputs: [
{
name: 'exec',
type: 'exec',
displayName: ''
},
{
name: 'stateMachineId',
type: 'string',
displayName: 'State Machine'
},
{
name: 'currentState',
type: 'string',
displayName: 'Current State'
},
{
name: 'previousState',
type: 'string',
displayName: 'Previous State'
}
]
};
/**
* @zh EventStateEnter 节点执行器
* @en EventStateEnter node executor
*/
@RegisterNode(EventStateEnterTemplate)
export class EventStateEnterExecutor implements INodeExecutor {
execute(node: BlueprintNode): ExecutionResult {
return {
nextExec: 'exec',
outputs: {
stateMachineId: '',
currentState: node.data?.stateName ?? '',
previousState: ''
}
};
}
}
/**
* @zh EventStateExit 节点模板
* @en EventStateExit node template
*/
export const EventStateExitTemplate: BlueprintNodeTemplate = {
type: 'EventStateExit',
title: 'Event State Exit',
category: 'event',
color: '#CC0000',
description: 'Triggered when exiting a state / 退出状态时触发',
keywords: ['state', 'exit', 'leave', 'fsm', 'machine', 'event'],
menuPath: ['Event', 'State', 'Exit'],
inputs: [
{
name: 'stateName',
type: 'string',
displayName: 'State Name',
defaultValue: ''
}
],
outputs: [
{
name: 'exec',
type: 'exec',
displayName: ''
},
{
name: 'stateMachineId',
type: 'string',
displayName: 'State Machine'
},
{
name: 'currentState',
type: 'string',
displayName: 'Current State'
},
{
name: 'previousState',
type: 'string',
displayName: 'Previous State'
}
]
};
/**
* @zh EventStateExit 节点执行器
* @en EventStateExit node executor
*/
@RegisterNode(EventStateExitTemplate)
export class EventStateExitExecutor implements INodeExecutor {
execute(node: BlueprintNode): ExecutionResult {
return {
nextExec: 'exec',
outputs: {
stateMachineId: '',
currentState: '',
previousState: node.data?.stateName ?? ''
}
};
}
}

View File

@@ -17,7 +17,8 @@ export const EventTickTemplate: BlueprintNodeTemplate = {
category: 'event',
color: '#CC0000',
description: 'Triggered every frame during execution (执行期间每帧触发)',
keywords: ['update', 'frame', 'tick', 'event'],
keywords: ['update', 'frame', 'tick', 'event', 'self'],
menuPath: ['Events', 'Tick'],
inputs: [],
outputs: [
{
@@ -25,6 +26,11 @@ export const EventTickTemplate: BlueprintNodeTemplate = {
type: 'exec',
displayName: ''
},
{
name: 'self',
type: 'entity',
displayName: 'Self'
},
{
name: 'deltaTime',
type: 'float',
@@ -43,6 +49,7 @@ export class EventTickExecutor implements INodeExecutor {
return {
nextExec: 'exec',
outputs: {
self: context.entity,
deltaTime: context.deltaTime
}
};

View File

@@ -1,70 +0,0 @@
/**
* @zh 定时器事件节点 - 定时器触发时调用
* @en Event Timer Node - Triggered when timer fires
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
/**
* @zh EventTimer 节点模板
* @en EventTimer node template
*/
export const EventTimerTemplate: BlueprintNodeTemplate = {
type: 'EventTimer',
title: 'Event Timer',
category: 'event',
color: '#CC0000',
description: 'Triggered when a timer fires / 定时器触发时执行',
keywords: ['timer', 'delay', 'schedule', 'event', 'interval'],
menuPath: ['Event', 'Timer'],
inputs: [
{
name: 'timerId',
type: 'string',
displayName: 'Timer ID',
defaultValue: ''
}
],
outputs: [
{
name: 'exec',
type: 'exec',
displayName: ''
},
{
name: 'timerId',
type: 'string',
displayName: 'Timer ID'
},
{
name: 'isRepeating',
type: 'bool',
displayName: 'Is Repeating'
},
{
name: 'timesFired',
type: 'int',
displayName: 'Times Fired'
}
]
};
/**
* @zh EventTimer 节点执行器
* @en EventTimer node executor
*/
@RegisterNode(EventTimerTemplate)
export class EventTimerExecutor implements INodeExecutor {
execute(node: BlueprintNode): ExecutionResult {
return {
nextExec: 'exec',
outputs: {
timerId: node.data?.timerId ?? '',
isRepeating: false,
timesFired: 0
}
};
}
}

View File

@@ -1,16 +1,8 @@
/**
* @zh 事件节点 - 蓝图执行的入口点
* @en Event Nodes - Entry points for blueprint execution
* @zh 生命周期事件节点 - 蓝图执行的入口点
* @en Lifecycle Event Nodes - Entry points for blueprint execution
*/
// 生命周期事件 | Lifecycle events
export * from './EventBeginPlay';
export * from './EventTick';
export * from './EventEndPlay';
// 触发器事件 | Trigger events
export * from './EventInput';
export * from './EventCollision';
export * from './EventMessage';
export * from './EventTimer';
export * from './EventState';

View File

@@ -1,11 +1,43 @@
/**
* Blueprint Nodes - All node definitions and executors
* 蓝图节点 - 所有节点定义和执行器
* @zh 蓝图节点 - 所有节点定义和执行器
* @en Blueprint Nodes - All node definitions and executors
*
* @zh 节点分类:
* - events: 生命周期事件BeginPlay, Tick, EndPlay
* - ecs: ECS 操作Entity, Component, Flow
* - variables: 变量读写
* - math: 数学运算
* - logic: 比较和逻辑运算
* - time: 时间工具
* - debug: 调试工具
*
* @en Node categories:
* - events: Lifecycle events (BeginPlay, Tick, EndPlay)
* - ecs: ECS operations (Entity, Component, Flow)
* - variables: Variable get/set
* - math: Math operations
* - logic: Comparison and logical operations
* - time: Time utilities
* - debug: Debug utilities
*/
// Import all nodes to trigger registration
// 导入所有节点以触发注册
// Lifecycle events | 生命周期事件
export * from './events';
export * from './debug';
export * from './time';
// ECS operations | ECS 操作
export * from './ecs';
// Variables | 变量
export * from './variables';
// Math operations | 数学运算
export * from './math';
// Logic operations | 逻辑运算
export * from './logic';
// Time utilities | 时间工具
export * from './time';
// Debug utilities | 调试工具
export * from './debug';

View File

@@ -0,0 +1,435 @@
/**
* @zh 比较运算节点
* @en Comparison Operation Nodes
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
// ============================================================================
// Equal Node (等于节点)
// ============================================================================
export const EqualTemplate: BlueprintNodeTemplate = {
type: 'Equal',
title: 'Equal',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if A equals B (如果 A 等于 B 则返回 true)',
keywords: ['equal', '==', 'same', 'compare', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'any', displayName: 'A' },
{ name: 'b', type: 'any', displayName: 'B' }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(EqualTemplate)
export class EqualExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = context.evaluateInput(node.id, 'a', null);
const b = context.evaluateInput(node.id, 'b', null);
return { outputs: { result: a === b } };
}
}
// ============================================================================
// Not Equal Node (不等于节点)
// ============================================================================
export const NotEqualTemplate: BlueprintNodeTemplate = {
type: 'NotEqual',
title: 'Not Equal',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if A does not equal B (如果 A 不等于 B 则返回 true)',
keywords: ['not', 'equal', '!=', 'different', 'compare', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'any', displayName: 'A' },
{ name: 'b', type: 'any', displayName: 'B' }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(NotEqualTemplate)
export class NotEqualExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = context.evaluateInput(node.id, 'a', null);
const b = context.evaluateInput(node.id, 'b', null);
return { outputs: { result: a !== b } };
}
}
// ============================================================================
// Greater Than Node (大于节点)
// ============================================================================
export const GreaterThanTemplate: BlueprintNodeTemplate = {
type: 'GreaterThan',
title: 'Greater Than',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if A is greater than B (如果 A 大于 B 则返回 true)',
keywords: ['greater', 'than', '>', 'compare', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(GreaterThanTemplate)
export class GreaterThanExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 0));
return { outputs: { result: a > b } };
}
}
// ============================================================================
// Greater Than Or Equal Node (大于等于节点)
// ============================================================================
export const GreaterThanOrEqualTemplate: BlueprintNodeTemplate = {
type: 'GreaterThanOrEqual',
title: 'Greater Or Equal',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if A is greater than or equal to B (如果 A 大于等于 B 则返回 true)',
keywords: ['greater', 'equal', '>=', 'compare', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(GreaterThanOrEqualTemplate)
export class GreaterThanOrEqualExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 0));
return { outputs: { result: a >= b } };
}
}
// ============================================================================
// Less Than Node (小于节点)
// ============================================================================
export const LessThanTemplate: BlueprintNodeTemplate = {
type: 'LessThan',
title: 'Less Than',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if A is less than B (如果 A 小于 B 则返回 true)',
keywords: ['less', 'than', '<', 'compare', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(LessThanTemplate)
export class LessThanExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 0));
return { outputs: { result: a < b } };
}
}
// ============================================================================
// Less Than Or Equal Node (小于等于节点)
// ============================================================================
export const LessThanOrEqualTemplate: BlueprintNodeTemplate = {
type: 'LessThanOrEqual',
title: 'Less Or Equal',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if A is less than or equal to B (如果 A 小于等于 B 则返回 true)',
keywords: ['less', 'equal', '<=', 'compare', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(LessThanOrEqualTemplate)
export class LessThanOrEqualExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 0));
return { outputs: { result: a <= b } };
}
}
// ============================================================================
// And Node (逻辑与节点)
// ============================================================================
export const AndTemplate: BlueprintNodeTemplate = {
type: 'And',
title: 'AND',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if both A and B are true (如果 A 和 B 都为 true 则返回 true)',
keywords: ['and', '&&', 'both', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'bool', displayName: 'A', defaultValue: false },
{ name: 'b', type: 'bool', displayName: 'B', defaultValue: false }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(AndTemplate)
export class AndExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Boolean(context.evaluateInput(node.id, 'a', false));
const b = Boolean(context.evaluateInput(node.id, 'b', false));
return { outputs: { result: a && b } };
}
}
// ============================================================================
// Or Node (逻辑或节点)
// ============================================================================
export const OrTemplate: BlueprintNodeTemplate = {
type: 'Or',
title: 'OR',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if either A or B is true (如果 A 或 B 为 true 则返回 true)',
keywords: ['or', '||', 'either', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'bool', displayName: 'A', defaultValue: false },
{ name: 'b', type: 'bool', displayName: 'B', defaultValue: false }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(OrTemplate)
export class OrExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Boolean(context.evaluateInput(node.id, 'a', false));
const b = Boolean(context.evaluateInput(node.id, 'b', false));
return { outputs: { result: a || b } };
}
}
// ============================================================================
// Not Node (逻辑非节点)
// ============================================================================
export const NotTemplate: BlueprintNodeTemplate = {
type: 'Not',
title: 'NOT',
category: 'logic',
color: '#9C27B0',
description: 'Returns the opposite boolean value (返回相反的布尔值)',
keywords: ['not', '!', 'negate', 'invert', 'logic'],
isPure: true,
inputs: [
{ name: 'value', type: 'bool', displayName: 'Value', defaultValue: false }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(NotTemplate)
export class NotExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Boolean(context.evaluateInput(node.id, 'value', false));
return { outputs: { result: !value } };
}
}
// ============================================================================
// XOR Node (异或节点)
// ============================================================================
export const XorTemplate: BlueprintNodeTemplate = {
type: 'Xor',
title: 'XOR',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if exactly one of A or B is true (如果 A 和 B 中恰好有一个为 true 则返回 true)',
keywords: ['xor', 'exclusive', 'or', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'bool', displayName: 'A', defaultValue: false },
{ name: 'b', type: 'bool', displayName: 'B', defaultValue: false }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(XorTemplate)
export class XorExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Boolean(context.evaluateInput(node.id, 'a', false));
const b = Boolean(context.evaluateInput(node.id, 'b', false));
return { outputs: { result: (a || b) && !(a && b) } };
}
}
// ============================================================================
// NAND Node (与非节点)
// ============================================================================
export const NandTemplate: BlueprintNodeTemplate = {
type: 'Nand',
title: 'NAND',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if not both A and B are true (如果 A 和 B 不都为 true 则返回 true)',
keywords: ['nand', 'not', 'and', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'bool', displayName: 'A', defaultValue: false },
{ name: 'b', type: 'bool', displayName: 'B', defaultValue: false }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(NandTemplate)
export class NandExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Boolean(context.evaluateInput(node.id, 'a', false));
const b = Boolean(context.evaluateInput(node.id, 'b', false));
return { outputs: { result: !(a && b) } };
}
}
// ============================================================================
// In Range Node (范围检查节点)
// ============================================================================
export const InRangeTemplate: BlueprintNodeTemplate = {
type: 'InRange',
title: 'In Range',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if value is between min and max (如果值在 min 和 max 之间则返回 true)',
keywords: ['range', 'between', 'check', 'logic'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 },
{ name: 'min', type: 'float', displayName: 'Min', defaultValue: 0 },
{ name: 'max', type: 'float', displayName: 'Max', defaultValue: 1 },
{ name: 'inclusive', type: 'bool', displayName: 'Inclusive', defaultValue: true }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(InRangeTemplate)
export class InRangeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
const min = Number(context.evaluateInput(node.id, 'min', 0));
const max = Number(context.evaluateInput(node.id, 'max', 1));
const inclusive = Boolean(context.evaluateInput(node.id, 'inclusive', true));
const result = inclusive
? value >= min && value <= max
: value > min && value < max;
return { outputs: { result } };
}
}
// ============================================================================
// Is Null Node (空值检查节点)
// ============================================================================
export const IsNullTemplate: BlueprintNodeTemplate = {
type: 'IsNull',
title: 'Is Null',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if the value is null or undefined (如果值为 null 或 undefined 则返回 true)',
keywords: ['null', 'undefined', 'empty', 'check', 'logic'],
isPure: true,
inputs: [
{ name: 'value', type: 'any', displayName: 'Value' }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Is Null' }
]
};
@RegisterNode(IsNullTemplate)
export class IsNullExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = context.evaluateInput(node.id, 'value', null);
return { outputs: { result: value == null } };
}
}
// ============================================================================
// Select Node (选择节点)
// ============================================================================
export const SelectTemplate: BlueprintNodeTemplate = {
type: 'Select',
title: 'Select',
category: 'logic',
color: '#9C27B0',
description: 'Returns A if condition is true, otherwise returns B (如果条件为 true 返回 A否则返回 B)',
keywords: ['select', 'choose', 'ternary', '?:', 'logic'],
isPure: true,
inputs: [
{ name: 'condition', type: 'bool', displayName: 'Condition', defaultValue: false },
{ name: 'a', type: 'any', displayName: 'A (True)' },
{ name: 'b', type: 'any', displayName: 'B (False)' }
],
outputs: [
{ name: 'result', type: 'any', displayName: 'Result' }
]
};
@RegisterNode(SelectTemplate)
export class SelectExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const condition = Boolean(context.evaluateInput(node.id, 'condition', false));
const a = context.evaluateInput(node.id, 'a', null);
const b = context.evaluateInput(node.id, 'b', null);
return { outputs: { result: condition ? a : b } };
}
}

View File

@@ -0,0 +1,6 @@
/**
* @zh 逻辑节点 - 比较和逻辑运算节点
* @en Logic Nodes - Comparison and logical operation nodes
*/
export * from './ComparisonNodes';

View File

@@ -120,3 +120,444 @@ export class DivideExecutor implements INodeExecutor {
return { outputs: { result: a / b } };
}
}
// ============================================================================
// Modulo Node (取模节点)
// ============================================================================
export const ModuloTemplate: BlueprintNodeTemplate = {
type: 'Modulo',
title: 'Modulo',
category: 'math',
color: '#4CAF50',
description: 'Returns the remainder of A divided by B (返回 A 除以 B 的余数)',
keywords: ['modulo', 'mod', 'remainder', '%', 'math'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 1 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(ModuloTemplate)
export class ModuloExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 1));
if (b === 0) return { outputs: { result: 0 } };
return { outputs: { result: a % b } };
}
}
// ============================================================================
// Absolute Value Node (绝对值节点)
// ============================================================================
export const AbsTemplate: BlueprintNodeTemplate = {
type: 'Abs',
title: 'Absolute',
category: 'math',
color: '#4CAF50',
description: 'Returns the absolute value (返回绝对值)',
keywords: ['abs', 'absolute', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(AbsTemplate)
export class AbsExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.abs(value) } };
}
}
// ============================================================================
// Min Node (最小值节点)
// ============================================================================
export const MinTemplate: BlueprintNodeTemplate = {
type: 'Min',
title: 'Min',
category: 'math',
color: '#4CAF50',
description: 'Returns the smaller of two values (返回两个值中较小的一个)',
keywords: ['min', 'minimum', 'smaller', 'math'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(MinTemplate)
export class MinExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 0));
return { outputs: { result: Math.min(a, b) } };
}
}
// ============================================================================
// Max Node (最大值节点)
// ============================================================================
export const MaxTemplate: BlueprintNodeTemplate = {
type: 'Max',
title: 'Max',
category: 'math',
color: '#4CAF50',
description: 'Returns the larger of two values (返回两个值中较大的一个)',
keywords: ['max', 'maximum', 'larger', 'math'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(MaxTemplate)
export class MaxExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 0));
return { outputs: { result: Math.max(a, b) } };
}
}
// ============================================================================
// Clamp Node (限制范围节点)
// ============================================================================
export const ClampTemplate: BlueprintNodeTemplate = {
type: 'Clamp',
title: 'Clamp',
category: 'math',
color: '#4CAF50',
description: 'Clamps a value between min and max (将值限制在最小和最大之间)',
keywords: ['clamp', 'limit', 'range', 'bound', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 },
{ name: 'min', type: 'float', displayName: 'Min', defaultValue: 0 },
{ name: 'max', type: 'float', displayName: 'Max', defaultValue: 1 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(ClampTemplate)
export class ClampExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
const min = Number(context.evaluateInput(node.id, 'min', 0));
const max = Number(context.evaluateInput(node.id, 'max', 1));
return { outputs: { result: Math.max(min, Math.min(max, value)) } };
}
}
// ============================================================================
// Lerp Node (线性插值节点)
// ============================================================================
export const LerpTemplate: BlueprintNodeTemplate = {
type: 'Lerp',
title: 'Lerp',
category: 'math',
color: '#4CAF50',
description: 'Linear interpolation between A and B (A 和 B 之间的线性插值)',
keywords: ['lerp', 'interpolate', 'blend', 'mix', 'math'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 1 },
{ name: 't', type: 'float', displayName: 'Alpha', defaultValue: 0.5 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(LerpTemplate)
export class LerpExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 1));
const t = Number(context.evaluateInput(node.id, 't', 0.5));
return { outputs: { result: a + (b - a) * t } };
}
}
// ============================================================================
// Random Range Node (随机范围节点)
// ============================================================================
export const RandomRangeTemplate: BlueprintNodeTemplate = {
type: 'RandomRange',
title: 'Random Range',
category: 'math',
color: '#4CAF50',
description: 'Returns a random number between min and max (返回 min 和 max 之间的随机数)',
keywords: ['random', 'range', 'rand', 'math'],
isPure: true,
inputs: [
{ name: 'min', type: 'float', displayName: 'Min', defaultValue: 0 },
{ name: 'max', type: 'float', displayName: 'Max', defaultValue: 1 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(RandomRangeTemplate)
export class RandomRangeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const min = Number(context.evaluateInput(node.id, 'min', 0));
const max = Number(context.evaluateInput(node.id, 'max', 1));
return { outputs: { result: min + Math.random() * (max - min) } };
}
}
// ============================================================================
// Random Integer Node (随机整数节点)
// ============================================================================
export const RandomIntTemplate: BlueprintNodeTemplate = {
type: 'RandomInt',
title: 'Random Integer',
category: 'math',
color: '#4CAF50',
description: 'Returns a random integer between min and max inclusive (返回 min 和 max 之间的随机整数,包含边界)',
keywords: ['random', 'int', 'integer', 'rand', 'math'],
isPure: true,
inputs: [
{ name: 'min', type: 'int', displayName: 'Min', defaultValue: 0 },
{ name: 'max', type: 'int', displayName: 'Max', defaultValue: 10 }
],
outputs: [
{ name: 'result', type: 'int', displayName: 'Result' }
]
};
@RegisterNode(RandomIntTemplate)
export class RandomIntExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const min = Math.floor(Number(context.evaluateInput(node.id, 'min', 0)));
const max = Math.floor(Number(context.evaluateInput(node.id, 'max', 10)));
return { outputs: { result: Math.floor(min + Math.random() * (max - min + 1)) } };
}
}
// ============================================================================
// Power Node (幂运算节点)
// ============================================================================
export const PowerTemplate: BlueprintNodeTemplate = {
type: 'Power',
title: 'Power',
category: 'math',
color: '#4CAF50',
description: 'Returns base raised to the power of exponent (返回底数的指数次幂)',
keywords: ['power', 'pow', 'exponent', '^', 'math'],
isPure: true,
inputs: [
{ name: 'base', type: 'float', displayName: 'Base', defaultValue: 2 },
{ name: 'exponent', type: 'float', displayName: 'Exponent', defaultValue: 2 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(PowerTemplate)
export class PowerExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const base = Number(context.evaluateInput(node.id, 'base', 2));
const exponent = Number(context.evaluateInput(node.id, 'exponent', 2));
return { outputs: { result: Math.pow(base, exponent) } };
}
}
// ============================================================================
// Square Root Node (平方根节点)
// ============================================================================
export const SqrtTemplate: BlueprintNodeTemplate = {
type: 'Sqrt',
title: 'Square Root',
category: 'math',
color: '#4CAF50',
description: 'Returns the square root (返回平方根)',
keywords: ['sqrt', 'square', 'root', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 4 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(SqrtTemplate)
export class SqrtExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 4));
return { outputs: { result: Math.sqrt(Math.abs(value)) } };
}
}
// ============================================================================
// Floor Node (向下取整节点)
// ============================================================================
export const FloorTemplate: BlueprintNodeTemplate = {
type: 'Floor',
title: 'Floor',
category: 'math',
color: '#4CAF50',
description: 'Rounds down to the nearest integer (向下取整)',
keywords: ['floor', 'round', 'down', 'int', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'int', displayName: 'Result' }
]
};
@RegisterNode(FloorTemplate)
export class FloorExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.floor(value) } };
}
}
// ============================================================================
// Ceil Node (向上取整节点)
// ============================================================================
export const CeilTemplate: BlueprintNodeTemplate = {
type: 'Ceil',
title: 'Ceil',
category: 'math',
color: '#4CAF50',
description: 'Rounds up to the nearest integer (向上取整)',
keywords: ['ceil', 'ceiling', 'round', 'up', 'int', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'int', displayName: 'Result' }
]
};
@RegisterNode(CeilTemplate)
export class CeilExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.ceil(value) } };
}
}
// ============================================================================
// Round Node (四舍五入节点)
// ============================================================================
export const RoundTemplate: BlueprintNodeTemplate = {
type: 'Round',
title: 'Round',
category: 'math',
color: '#4CAF50',
description: 'Rounds to the nearest integer (四舍五入到最近的整数)',
keywords: ['round', 'int', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'int', displayName: 'Result' }
]
};
@RegisterNode(RoundTemplate)
export class RoundExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.round(value) } };
}
}
// ============================================================================
// Negate Node (取反节点)
// ============================================================================
export const NegateTemplate: BlueprintNodeTemplate = {
type: 'Negate',
title: 'Negate',
category: 'math',
color: '#4CAF50',
description: 'Returns the negative of a value (返回值的负数)',
keywords: ['negate', 'negative', 'minus', '-', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(NegateTemplate)
export class NegateExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: -value } };
}
}
// ============================================================================
// Sign Node (符号节点)
// ============================================================================
export const SignTemplate: BlueprintNodeTemplate = {
type: 'Sign',
title: 'Sign',
category: 'math',
color: '#4CAF50',
description: 'Returns -1, 0, or 1 based on the sign of the value (根据值的符号返回 -1、0 或 1)',
keywords: ['sign', 'positive', 'negative', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'int', displayName: 'Result' }
]
};
@RegisterNode(SignTemplate)
export class SignExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.sign(value) } };
}
}

View File

@@ -0,0 +1,189 @@
/**
* @zh 变量节点 - 读取和设置蓝图变量
* @en Variable Nodes - Get and set blueprint variables
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
// ============================================================================
// Get Variable | 获取变量
// ============================================================================
export const GetVariableTemplate: BlueprintNodeTemplate = {
type: 'GetVariable',
title: 'Get Variable',
category: 'variable',
color: '#4a9c6d',
isPure: true,
description: 'Gets the value of a variable (获取变量的值)',
keywords: ['variable', 'get', 'read', 'value'],
menuPath: ['Variable', 'Get Variable'],
inputs: [
{ name: 'variableName', type: 'string', displayName: 'Variable Name', defaultValue: '' }
],
outputs: [
{ name: 'value', type: 'any', displayName: 'Value' }
]
};
@RegisterNode(GetVariableTemplate)
export class GetVariableExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const variableName = context.evaluateInput(node.id, 'variableName', '') as string;
if (!variableName) {
return { outputs: { value: null } };
}
const value = context.getVariable(variableName);
return { outputs: { value } };
}
}
// ============================================================================
// Set Variable | 设置变量
// ============================================================================
export const SetVariableTemplate: BlueprintNodeTemplate = {
type: 'SetVariable',
title: 'Set Variable',
category: 'variable',
color: '#4a9c6d',
description: 'Sets the value of a variable (设置变量的值)',
keywords: ['variable', 'set', 'write', 'assign', 'value'],
menuPath: ['Variable', 'Set Variable'],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'variableName', type: 'string', displayName: 'Variable Name', defaultValue: '' },
{ name: 'value', type: 'any', displayName: 'Value' }
],
outputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'value', type: 'any', displayName: 'Value' }
]
};
@RegisterNode(SetVariableTemplate)
export class SetVariableExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const variableName = context.evaluateInput(node.id, 'variableName', '') as string;
const value = context.evaluateInput(node.id, 'value', null);
if (!variableName) {
return { outputs: { value: null }, nextExec: 'exec' };
}
context.setVariable(variableName, value);
return { outputs: { value }, nextExec: 'exec' };
}
}
// ============================================================================
// Get Variable By Name (typed variants) | 按名称获取变量(类型变体)
// ============================================================================
export const GetBoolVariableTemplate: BlueprintNodeTemplate = {
type: 'GetBoolVariable',
title: 'Get Bool',
category: 'variable',
color: '#8b1e3f',
isPure: true,
description: 'Gets a boolean variable (获取布尔变量)',
keywords: ['variable', 'get', 'bool', 'boolean'],
menuPath: ['Variable', 'Get Bool'],
inputs: [
{ name: 'variableName', type: 'string', displayName: 'Variable Name', defaultValue: '' }
],
outputs: [
{ name: 'value', type: 'bool', displayName: 'Value' }
]
};
@RegisterNode(GetBoolVariableTemplate)
export class GetBoolVariableExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const variableName = context.evaluateInput(node.id, 'variableName', '') as string;
const value = context.getVariable(variableName);
return { outputs: { value: Boolean(value) } };
}
}
export const GetFloatVariableTemplate: BlueprintNodeTemplate = {
type: 'GetFloatVariable',
title: 'Get Float',
category: 'variable',
color: '#39c5bb',
isPure: true,
description: 'Gets a float variable (获取浮点变量)',
keywords: ['variable', 'get', 'float', 'number'],
menuPath: ['Variable', 'Get Float'],
inputs: [
{ name: 'variableName', type: 'string', displayName: 'Variable Name', defaultValue: '' }
],
outputs: [
{ name: 'value', type: 'float', displayName: 'Value' }
]
};
@RegisterNode(GetFloatVariableTemplate)
export class GetFloatVariableExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const variableName = context.evaluateInput(node.id, 'variableName', '') as string;
const value = context.getVariable(variableName);
return { outputs: { value: Number(value) || 0 } };
}
}
export const GetIntVariableTemplate: BlueprintNodeTemplate = {
type: 'GetIntVariable',
title: 'Get Int',
category: 'variable',
color: '#1c8b8b',
isPure: true,
description: 'Gets an integer variable (获取整数变量)',
keywords: ['variable', 'get', 'int', 'integer', 'number'],
menuPath: ['Variable', 'Get Int'],
inputs: [
{ name: 'variableName', type: 'string', displayName: 'Variable Name', defaultValue: '' }
],
outputs: [
{ name: 'value', type: 'int', displayName: 'Value' }
]
};
@RegisterNode(GetIntVariableTemplate)
export class GetIntVariableExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const variableName = context.evaluateInput(node.id, 'variableName', '') as string;
const value = context.getVariable(variableName);
return { outputs: { value: Math.floor(Number(value) || 0) } };
}
}
export const GetStringVariableTemplate: BlueprintNodeTemplate = {
type: 'GetStringVariable',
title: 'Get String',
category: 'variable',
color: '#e91e8c',
isPure: true,
description: 'Gets a string variable (获取字符串变量)',
keywords: ['variable', 'get', 'string', 'text'],
menuPath: ['Variable', 'Get String'],
inputs: [
{ name: 'variableName', type: 'string', displayName: 'Variable Name', defaultValue: '' }
],
outputs: [
{ name: 'value', type: 'string', displayName: 'Value' }
]
};
@RegisterNode(GetStringVariableTemplate)
export class GetStringVariableExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const variableName = context.evaluateInput(node.id, 'variableName', '') as string;
const value = context.getVariable(variableName);
return { outputs: { value: String(value ?? '') } };
}
}

View File

@@ -0,0 +1,6 @@
/**
* @zh 变量节点导出
* @en Variable nodes export
*/
export * from './VariableNodes';

View File

@@ -0,0 +1,334 @@
/**
* @zh 蓝图装饰器 - 用于标记可在蓝图中使用的组件、属性和方法
* @en Blueprint Decorators - Mark components, properties and methods for blueprint use
*
* @example
* ```typescript
* import { BlueprintExpose, BlueprintProperty, BlueprintMethod } from '@esengine/blueprint';
*
* @ECSComponent('Health')
* @BlueprintExpose({ displayName: '生命值组件', category: 'gameplay' })
* export class HealthComponent extends Component {
*
* @BlueprintProperty({ displayName: '当前生命值', type: 'float' })
* current: number = 100;
*
* @BlueprintProperty({ displayName: '最大生命值', type: 'float', readonly: true })
* max: number = 100;
*
* @BlueprintMethod({
* displayName: '治疗',
* params: [{ name: 'amount', type: 'float' }]
* })
* heal(amount: number): void {
* this.current = Math.min(this.current + amount, this.max);
* }
*
* @BlueprintMethod({
* displayName: '受伤',
* params: [{ name: 'amount', type: 'float' }],
* returnType: 'bool'
* })
* takeDamage(amount: number): boolean {
* this.current -= amount;
* return this.current <= 0;
* }
* }
* ```
*/
import type { BlueprintPinType } from '../types/pins';
// ============================================================================
// Types | 类型定义
// ============================================================================
/**
* @zh 参数定义
* @en Parameter definition
*/
export interface BlueprintParamDef {
/** @zh 参数名称 @en Parameter name */
name: string;
/** @zh 显示名称 @en Display name */
displayName?: string;
/** @zh 引脚类型 @en Pin type */
type?: BlueprintPinType;
/** @zh 默认值 @en Default value */
defaultValue?: unknown;
}
/**
* @zh 蓝图暴露选项
* @en Blueprint expose options
*/
export interface BlueprintExposeOptions {
/** @zh 组件显示名称 @en Component display name */
displayName?: string;
/** @zh 组件描述 @en Component description */
description?: string;
/** @zh 组件分类 @en Component category */
category?: string;
/** @zh 组件颜色 @en Component color */
color?: string;
/** @zh 组件图标 @en Component icon */
icon?: string;
}
/**
* @zh 蓝图属性选项
* @en Blueprint property options
*/
export interface BlueprintPropertyOptions {
/** @zh 属性显示名称 @en Property display name */
displayName?: string;
/** @zh 属性描述 @en Property description */
description?: string;
/** @zh 引脚类型 @en Pin type */
type?: BlueprintPinType;
/** @zh 是否只读(不生成 Set 节点)@en Readonly (no Set node generated) */
readonly?: boolean;
/** @zh 默认值 @en Default value */
defaultValue?: unknown;
}
/**
* @zh 蓝图方法选项
* @en Blueprint method options
*/
export interface BlueprintMethodOptions {
/** @zh 方法显示名称 @en Method display name */
displayName?: string;
/** @zh 方法描述 @en Method description */
description?: string;
/** @zh 是否是纯函数(无副作用)@en Is pure function (no side effects) */
isPure?: boolean;
/** @zh 参数列表 @en Parameter list */
params?: BlueprintParamDef[];
/** @zh 返回值类型 @en Return type */
returnType?: BlueprintPinType;
}
/**
* @zh 属性元数据
* @en Property metadata
*/
export interface PropertyMetadata {
propertyKey: string;
displayName: string;
description?: string;
pinType: BlueprintPinType;
readonly: boolean;
defaultValue?: unknown;
}
/**
* @zh 方法元数据
* @en Method metadata
*/
export interface MethodMetadata {
methodKey: string;
displayName: string;
description?: string;
isPure: boolean;
params: BlueprintParamDef[];
returnType: BlueprintPinType;
}
/**
* @zh 组件蓝图元数据
* @en Component blueprint metadata
*/
export interface ComponentBlueprintMetadata extends BlueprintExposeOptions {
componentName: string;
properties: PropertyMetadata[];
methods: MethodMetadata[];
}
// ============================================================================
// Registry | 注册表
// ============================================================================
/**
* @zh 已注册的蓝图组件
* @en Registered blueprint components
*/
const registeredComponents = new Map<Function, ComponentBlueprintMetadata>();
/**
* @zh 获取所有已注册的蓝图组件
* @en Get all registered blueprint components
*/
export function getRegisteredBlueprintComponents(): Map<Function, ComponentBlueprintMetadata> {
return registeredComponents;
}
/**
* @zh 获取组件的蓝图元数据
* @en Get blueprint metadata for a component
*/
export function getBlueprintMetadata(componentClass: Function): ComponentBlueprintMetadata | undefined {
return registeredComponents.get(componentClass);
}
/**
* @zh 清除所有注册的蓝图组件(用于测试)
* @en Clear all registered blueprint components (for testing)
*/
export function clearRegisteredComponents(): void {
registeredComponents.clear();
}
// ============================================================================
// Internal Helpers | 内部辅助函数
// ============================================================================
function getOrCreateMetadata(constructor: Function): ComponentBlueprintMetadata {
let metadata = registeredComponents.get(constructor);
if (!metadata) {
metadata = {
componentName: (constructor as any).__componentName__ ?? constructor.name,
properties: [],
methods: []
};
registeredComponents.set(constructor, metadata);
}
return metadata;
}
// ============================================================================
// Decorators | 装饰器
// ============================================================================
/**
* @zh 标记组件可在蓝图中使用
* @en Mark component as usable in blueprint
*
* @example
* ```typescript
* @ECSComponent('Player')
* @BlueprintExpose({ displayName: '玩家', category: 'gameplay' })
* export class PlayerComponent extends Component { }
* ```
*/
export function BlueprintExpose(options: BlueprintExposeOptions = {}): ClassDecorator {
return function (target: Function) {
const metadata = getOrCreateMetadata(target);
Object.assign(metadata, options);
metadata.componentName = (target as any).__componentName__ ?? target.name;
return target as any;
};
}
/**
* @zh 标记属性可在蓝图中访问
* @en Mark property as accessible in blueprint
*
* @example
* ```typescript
* @BlueprintProperty({ displayName: '生命值', type: 'float' })
* health: number = 100;
*
* @BlueprintProperty({ displayName: '名称', type: 'string', readonly: true })
* name: string = 'Player';
* ```
*/
export function BlueprintProperty(options: BlueprintPropertyOptions = {}): PropertyDecorator {
return function (target: Object, propertyKey: string | symbol) {
const key = String(propertyKey);
const metadata = getOrCreateMetadata(target.constructor);
const propMeta: PropertyMetadata = {
propertyKey: key,
displayName: options.displayName ?? key,
description: options.description,
pinType: options.type ?? 'any',
readonly: options.readonly ?? false,
defaultValue: options.defaultValue
};
const existingIndex = metadata.properties.findIndex(p => p.propertyKey === key);
if (existingIndex >= 0) {
metadata.properties[existingIndex] = propMeta;
} else {
metadata.properties.push(propMeta);
}
};
}
/**
* @zh 标记方法可在蓝图中调用
* @en Mark method as callable in blueprint
*
* @example
* ```typescript
* @BlueprintMethod({
* displayName: '攻击',
* params: [
* { name: 'target', type: 'entity' },
* { name: 'damage', type: 'float' }
* ],
* returnType: 'bool'
* })
* attack(target: Entity, damage: number): boolean { }
*
* @BlueprintMethod({ displayName: '获取速度', isPure: true, returnType: 'float' })
* getSpeed(): number { return this.speed; }
* ```
*/
export function BlueprintMethod(options: BlueprintMethodOptions = {}): MethodDecorator {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
const key = String(propertyKey);
const metadata = getOrCreateMetadata(target.constructor);
const methodMeta: MethodMetadata = {
methodKey: key,
displayName: options.displayName ?? key,
description: options.description,
isPure: options.isPure ?? false,
params: options.params ?? [],
returnType: options.returnType ?? 'any'
};
const existingIndex = metadata.methods.findIndex(m => m.methodKey === key);
if (existingIndex >= 0) {
metadata.methods[existingIndex] = methodMeta;
} else {
metadata.methods.push(methodMeta);
}
return descriptor;
};
}
// ============================================================================
// Utility Functions | 工具函数
// ============================================================================
/**
* @zh 从 TypeScript 类型名推断蓝图引脚类型
* @en Infer blueprint pin type from TypeScript type name
*/
export function inferPinType(typeName: string): BlueprintPinType {
const typeMap: Record<string, BlueprintPinType> = {
'number': 'float',
'Number': 'float',
'string': 'string',
'String': 'string',
'boolean': 'bool',
'Boolean': 'bool',
'Entity': 'entity',
'Component': 'component',
'Vector2': 'vector2',
'Vec2': 'vector2',
'Vector3': 'vector3',
'Vec3': 'vector3',
'Color': 'color',
'Array': 'array',
'Object': 'object',
'void': 'exec',
'undefined': 'exec'
};
return typeMap[typeName] ?? 'any';
}

View File

@@ -0,0 +1,447 @@
/**
* @zh 组件节点生成器 - 自动为标记的组件生成蓝图节点
* @en Component Node Generator - Auto-generate blueprint nodes for marked components
*
* @zh 根据 @BlueprintExpose、@BlueprintProperty、@BlueprintMethod 装饰器
* 自动生成对应的 Get/Set/Call 节点并注册到 NodeRegistry
*
* @en Based on @BlueprintExpose, @BlueprintProperty, @BlueprintMethod decorators,
* auto-generate corresponding Get/Set/Call nodes and register to NodeRegistry
*/
import type { Component, Entity } from '@esengine/ecs-framework';
import type { BlueprintNodeTemplate, BlueprintNode } from '../types/nodes';
import type { BlueprintPinType } from '../types/pins';
import type { ExecutionContext, ExecutionResult } from '../runtime/ExecutionContext';
import type { INodeExecutor } from '../runtime/NodeRegistry';
import { NodeRegistry } from '../runtime/NodeRegistry';
import {
getRegisteredBlueprintComponents,
type ComponentBlueprintMetadata,
type PropertyMetadata,
type MethodMetadata
} from './BlueprintDecorators';
// ============================================================================
// Node Generator | 节点生成器
// ============================================================================
/**
* @zh 为组件生成所有蓝图节点
* @en Generate all blueprint nodes for a component
*/
export function generateComponentNodes(
componentClass: Function,
metadata: ComponentBlueprintMetadata
): void {
const { componentName, properties, methods } = metadata;
const category = metadata.category ?? 'component';
const color = metadata.color ?? '#1e8b8b';
// Generate Add/Get component nodes
generateAddComponentNode(componentClass, componentName, metadata, color);
generateGetComponentNode(componentClass, componentName, metadata, color);
for (const prop of properties) {
generatePropertyGetNode(componentName, prop, category, color);
if (!prop.readonly) {
generatePropertySetNode(componentName, prop, category, color);
}
}
for (const method of methods) {
generateMethodCallNode(componentName, method, category, color);
}
}
/**
* @zh 生成 Add Component 节点
* @en Generate Add Component node
*/
function generateAddComponentNode(
componentClass: Function,
componentName: string,
metadata: ComponentBlueprintMetadata,
color: string
): void {
const nodeType = `Add_${componentName}`;
const displayName = metadata.displayName ?? componentName;
// Build input pins for initial property values
const propertyInputs: BlueprintNodeTemplate['inputs'] = [];
const propertyDefaults: Record<string, unknown> = {};
for (const prop of metadata.properties) {
if (!prop.readonly) {
propertyInputs.push({
name: prop.propertyKey,
type: prop.pinType,
displayName: prop.displayName,
defaultValue: prop.defaultValue
});
propertyDefaults[prop.propertyKey] = prop.defaultValue;
}
}
const template: BlueprintNodeTemplate = {
type: nodeType,
title: `Add ${displayName}`,
category: 'component',
color,
description: `Adds ${displayName} component to entity (为实体添加 ${displayName} 组件)`,
keywords: ['add', 'component', 'create', componentName.toLowerCase()],
menuPath: ['Components', displayName, `Add ${displayName}`],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'entity', type: 'entity', displayName: 'Entity' },
...propertyInputs
],
outputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'component', type: 'component', displayName: displayName },
{ name: 'success', type: 'bool', displayName: 'Success' }
]
};
const propertyKeys = metadata.properties
.filter(p => !p.readonly)
.map(p => p.propertyKey);
const executor: INodeExecutor = {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
if (!entity || entity.isDestroyed) {
return { outputs: { component: null, success: false }, nextExec: 'exec' };
}
// Check if component already exists
const existing = entity.components.find(c =>
c.constructor === componentClass ||
c.constructor.name === componentName ||
(c.constructor as any).__componentName__ === componentName
);
if (existing) {
// Component already exists, return it
return { outputs: { component: existing, success: false }, nextExec: 'exec' };
}
try {
// Create new component instance
const component = new (componentClass as new () => Component)();
// Set initial property values from inputs
for (const key of propertyKeys) {
const value = context.evaluateInput(node.id, key, propertyDefaults[key]);
if (value !== undefined) {
(component as any)[key] = value;
}
}
// Add to entity
entity.addComponent(component);
return { outputs: { component, success: true }, nextExec: 'exec' };
} catch (error) {
console.error(`[Blueprint] Failed to add ${componentName}:`, error);
return { outputs: { component: null, success: false }, nextExec: 'exec' };
}
}
};
NodeRegistry.instance.register(template, executor);
}
/**
* @zh 生成 Get Component 节点
* @en Generate Get Component node
*/
function generateGetComponentNode(
componentClass: Function,
componentName: string,
metadata: ComponentBlueprintMetadata,
color: string
): void {
const nodeType = `Get_${componentName}`;
const displayName = metadata.displayName ?? componentName;
const template: BlueprintNodeTemplate = {
type: nodeType,
title: `Get ${displayName}`,
category: 'component',
color,
isPure: true,
description: `Gets ${displayName} component from entity (从实体获取 ${displayName} 组件)`,
keywords: ['get', 'component', componentName.toLowerCase()],
menuPath: ['Components', displayName, `Get ${displayName}`],
inputs: [
{ name: 'entity', type: 'entity', displayName: 'Entity' }
],
outputs: [
{ name: 'component', type: 'component', displayName: displayName },
{ name: 'found', type: 'bool', displayName: 'Found' }
]
};
const executor: INodeExecutor = {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
if (!entity || entity.isDestroyed) {
return { outputs: { component: null, found: false } };
}
const component = entity.components.find(c =>
c.constructor === componentClass ||
c.constructor.name === componentName ||
(c.constructor as any).__componentName__ === componentName
);
return {
outputs: {
component: component ?? null,
found: component != null
}
};
}
};
NodeRegistry.instance.register(template, executor);
}
/**
* @zh 生成属性 Get 节点
* @en Generate property Get node
*/
function generatePropertyGetNode(
componentName: string,
prop: PropertyMetadata,
category: string,
color: string
): void {
const nodeType = `Get_${componentName}_${prop.propertyKey}`;
const { displayName, pinType } = prop;
const template: BlueprintNodeTemplate = {
type: nodeType,
title: `Get ${displayName}`,
subtitle: componentName,
category: category as any,
color,
isPure: true,
description: prop.description ?? `Gets ${displayName} from ${componentName} (从 ${componentName} 获取 ${displayName})`,
keywords: ['get', 'property', componentName.toLowerCase(), prop.propertyKey.toLowerCase()],
menuPath: ['Components', componentName, `Get ${displayName}`],
inputs: [
{ name: 'component', type: 'component', displayName: componentName }
],
outputs: [
{ name: 'value', type: pinType, displayName }
]
};
const propertyKey = prop.propertyKey;
const defaultValue = prop.defaultValue;
const executor: INodeExecutor = {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const component = context.evaluateInput(node.id, 'component', null) as Component | null;
if (!component) {
return { outputs: { value: defaultValue ?? null } };
}
const value = (component as any)[propertyKey];
return { outputs: { value } };
}
};
NodeRegistry.instance.register(template, executor);
}
/**
* @zh 生成属性 Set 节点
* @en Generate property Set node
*/
function generatePropertySetNode(
componentName: string,
prop: PropertyMetadata,
category: string,
color: string
): void {
const nodeType = `Set_${componentName}_${prop.propertyKey}`;
const { displayName, pinType, defaultValue } = prop;
const template: BlueprintNodeTemplate = {
type: nodeType,
title: `Set ${displayName}`,
subtitle: componentName,
category: category as any,
color,
description: prop.description ?? `Sets ${displayName} on ${componentName} (设置 ${componentName}${displayName})`,
keywords: ['set', 'property', componentName.toLowerCase(), prop.propertyKey.toLowerCase()],
menuPath: ['Components', componentName, `Set ${displayName}`],
inputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'component', type: 'component', displayName: componentName },
{ name: 'value', type: pinType, displayName, defaultValue }
],
outputs: [
{ name: 'exec', type: 'exec', displayName: '' }
]
};
const propertyKey = prop.propertyKey;
const executor: INodeExecutor = {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const component = context.evaluateInput(node.id, 'component', null) as Component | null;
const value = context.evaluateInput(node.id, 'value', defaultValue);
if (component) {
(component as any)[propertyKey] = value;
}
return { nextExec: 'exec' };
}
};
NodeRegistry.instance.register(template, executor);
}
/**
* @zh 生成方法调用节点
* @en Generate method call node
*/
function generateMethodCallNode(
componentName: string,
method: MethodMetadata,
category: string,
color: string
): void {
const nodeType = `Call_${componentName}_${method.methodKey}`;
const { displayName, isPure, params, returnType } = method;
const inputs: BlueprintNodeTemplate['inputs'] = [];
if (!isPure) {
inputs.push({ name: 'exec', type: 'exec', displayName: '' });
}
inputs.push({ name: 'component', type: 'component', displayName: componentName });
const paramNames: string[] = [];
for (const param of params) {
inputs.push({
name: param.name,
type: param.type ?? 'any',
displayName: param.displayName ?? param.name,
defaultValue: param.defaultValue
});
paramNames.push(param.name);
}
const outputs: BlueprintNodeTemplate['outputs'] = [];
if (!isPure) {
outputs.push({ name: 'exec', type: 'exec', displayName: '' });
}
if (returnType !== 'exec' && returnType !== 'any') {
outputs.push({
name: 'result',
type: returnType as BlueprintPinType,
displayName: 'Result'
});
}
const template: BlueprintNodeTemplate = {
type: nodeType,
title: displayName,
subtitle: componentName,
category: category as any,
color,
isPure,
description: method.description ?? `Calls ${displayName} on ${componentName} (调用 ${componentName}${displayName})`,
keywords: ['call', 'method', componentName.toLowerCase(), method.methodKey.toLowerCase()],
menuPath: ['Components', componentName, displayName],
inputs,
outputs
};
const methodKey = method.methodKey;
const executor: INodeExecutor = {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const component = context.evaluateInput(node.id, 'component', null) as Component | null;
if (!component) {
return isPure ? { outputs: { result: null } } : { nextExec: 'exec' };
}
const args: unknown[] = paramNames.map(name =>
context.evaluateInput(node.id, name, undefined)
);
const fn = (component as any)[methodKey];
if (typeof fn !== 'function') {
console.warn(`Method ${methodKey} not found on component ${componentName}`);
return isPure ? { outputs: { result: null } } : { nextExec: 'exec' };
}
const result = fn.apply(component, args);
return isPure
? { outputs: { result } }
: { outputs: { result }, nextExec: 'exec' };
}
};
NodeRegistry.instance.register(template, executor);
}
// ============================================================================
// Registration | 注册
// ============================================================================
/**
* @zh 注册所有已标记的组件节点
* @en Register all marked component nodes
*
* @zh 应该在蓝图系统初始化时调用,会扫描所有使用 @BlueprintExpose 装饰的组件
* 并自动生成对应的蓝图节点
*
* @en Should be called during blueprint system initialization, scans all components
* decorated with @BlueprintExpose and auto-generates corresponding blueprint nodes
*/
export function registerAllComponentNodes(): void {
const components = getRegisteredBlueprintComponents();
for (const [componentClass, metadata] of components) {
try {
generateComponentNodes(componentClass, metadata);
console.log(`[Blueprint] Registered component: ${metadata.componentName} (${metadata.properties.length} properties, ${metadata.methods.length} methods)`);
} catch (error) {
console.error(`[Blueprint] Failed to register component ${metadata.componentName}:`, error);
}
}
console.log(`[Blueprint] Registered ${components.size} component(s)`);
}
/**
* @zh 手动注册单个组件
* @en Manually register a single component
*/
export function registerComponentNodes(componentClass: Function): void {
const components = getRegisteredBlueprintComponents();
const metadata = components.get(componentClass);
if (!metadata) {
console.warn(`[Blueprint] Component ${componentClass.name} is not marked with @BlueprintExpose`);
return;
}
generateComponentNodes(componentClass, metadata);
}

View File

@@ -0,0 +1,69 @@
/**
* @zh 蓝图注册系统
* @en Blueprint Registry System
*
* @zh 提供组件自动节点生成功能,用户只需使用装饰器标记组件,
* 即可自动在蓝图编辑器中生成对应的 Get/Set/Call 节点
*
* @en Provides automatic node generation for components. Users only need to
* mark components with decorators, and corresponding Get/Set/Call nodes
* will be auto-generated in the blueprint editor
*
* @example
* ```typescript
* // 1. 定义组件时使用装饰器 | Define component with decorators
* @ECSComponent('Health')
* @BlueprintExpose({ displayName: '生命值', category: 'gameplay' })
* export class HealthComponent extends Component {
* @BlueprintProperty({ displayName: '当前生命值', type: 'float' })
* current: number = 100;
*
* @BlueprintMethod({
* displayName: '治疗',
* params: [{ name: 'amount', type: 'float' }]
* })
* heal(amount: number): void {
* this.current = Math.min(this.current + amount, 100);
* }
* }
*
* // 2. 初始化蓝图系统时注册 | Register when initializing blueprint system
* import { registerAllComponentNodes } from '@esengine/blueprint';
* registerAllComponentNodes();
*
* // 3. 现在蓝图编辑器中会出现以下节点:
* // Now these nodes appear in blueprint editor:
* // - Get Health获取组件
* // - Get 当前生命值(获取属性)
* // - Set 当前生命值(设置属性)
* // - 治疗(调用方法)
* ```
*/
// Decorators | 装饰器
export {
BlueprintExpose,
BlueprintProperty,
BlueprintMethod,
getRegisteredBlueprintComponents,
getBlueprintMetadata,
clearRegisteredComponents,
inferPinType
} from './BlueprintDecorators';
export type {
BlueprintParamDef,
BlueprintExposeOptions,
BlueprintPropertyOptions,
BlueprintMethodOptions,
PropertyMetadata,
MethodMetadata,
ComponentBlueprintMetadata
} from './BlueprintDecorators';
// Node Generator | 节点生成器
export {
generateComponentNodes,
registerAllComponentNodes,
registerComponentNodes
} from './ComponentNodeGenerator';

View File

@@ -1,116 +1,117 @@
/**
* Blueprint Component - Attaches a blueprint to an entity
* 蓝图组件 - 将蓝图附加到实体
* @zh 蓝图组件 - 将蓝图附加到实体
* @en Blueprint Component - Attaches a blueprint to an entity
*/
import type { Entity, IScene } from '@esengine/ecs-framework';
import { Component, ECSComponent, type Entity, type IScene } from '@esengine/ecs-framework';
import { BlueprintAsset } from '../types/blueprint';
import { BlueprintVM } from './BlueprintVM';
/**
* Component interface for ECS integration
* 用于 ECS 集成的组件接口
* @zh 蓝图组件,用于将可视化脚本附加到 ECS 实体
* @en Blueprint component for attaching visual scripts to ECS entities
*
* @example
* ```typescript
* const entity = scene.createEntity('Player');
* const blueprint = new BlueprintComponent();
* blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
* blueprint.autoStart = true;
* entity.addComponent(blueprint);
* ```
*/
export interface IBlueprintComponent {
/** Entity ID this component belongs to (此组件所属的实体ID) */
entityId: number | null;
@ECSComponent('Blueprint')
export class BlueprintComponent extends Component {
/**
* @zh 蓝图资产引用
* @en Blueprint asset reference
*/
blueprintAsset: BlueprintAsset | null = null;
/** Blueprint asset reference (蓝图资产引用) */
blueprintAsset: BlueprintAsset | null;
/**
* @zh 用于序列化的蓝图资产路径
* @en Blueprint asset path for serialization
*/
blueprintPath: string = '';
/** Blueprint asset path for serialization (用于序列化的蓝图资产路径) */
blueprintPath: string;
/**
* @zh 实体创建时自动开始执行
* @en Auto-start execution when entity is created
*/
autoStart: boolean = true;
/** Auto-start execution when entity is created (实体创建时自动开始执行) */
autoStart: boolean;
/**
* @zh 启用 VM 调试模式
* @en Enable debug mode for VM
*/
debug: boolean = false;
/** Enable debug mode for VM (启用 VM 调试模式) */
debug: boolean;
/**
* @zh 运行时 VM 实例
* @en Runtime VM instance
*/
vm: BlueprintVM | null = null;
/** Runtime VM instance (运行时 VM 实例) */
vm: BlueprintVM | null;
/**
* @zh 蓝图是否已启动
* @en Whether the blueprint has started
*/
isStarted: boolean = false;
/** Whether the blueprint has started (蓝图是否已启动) */
isStarted: boolean;
}
/**
* @zh 初始化蓝图 VM
* @en Initialize blueprint VM
*/
initialize(entity: Entity, scene: IScene): void {
if (!this.blueprintAsset) return;
/**
* Creates a blueprint component data object
* 创建蓝图组件数据对象
*/
export function createBlueprintComponentData(): IBlueprintComponent {
return {
entityId: null,
blueprintAsset: null,
blueprintPath: '',
autoStart: true,
debug: false,
vm: null,
isStarted: false
};
}
/**
* Initialize the VM for a blueprint component
* 为蓝图组件初始化 VM
*/
export function initializeBlueprintVM(
component: IBlueprintComponent,
entity: Entity,
scene: IScene
): void {
if (!component.blueprintAsset) {
return;
this.vm = new BlueprintVM(this.blueprintAsset, entity, scene);
this.vm.debug = this.debug;
}
// Create VM instance
// 创建 VM 实例
component.vm = new BlueprintVM(component.blueprintAsset, entity, scene);
component.vm.debug = component.debug;
}
/**
* Start blueprint execution
* 开始蓝图执行
*/
export function startBlueprint(component: IBlueprintComponent): void {
if (component.vm && !component.isStarted) {
component.vm.start();
component.isStarted = true;
}
}
/**
* Stop blueprint execution
* 停止蓝图执行
*/
export function stopBlueprint(component: IBlueprintComponent): void {
if (component.vm && component.isStarted) {
component.vm.stop();
component.isStarted = false;
}
}
/**
* Update blueprint execution
* 更新蓝图执行
*/
export function tickBlueprint(component: IBlueprintComponent, deltaTime: number): void {
if (component.vm && component.isStarted) {
component.vm.tick(deltaTime);
}
}
/**
* Clean up blueprint resources
* 清理蓝图资源
*/
export function cleanupBlueprint(component: IBlueprintComponent): void {
if (component.vm) {
if (component.isStarted) {
component.vm.stop();
/**
* @zh 开始执行蓝图
* @en Start blueprint execution
*/
start(): void {
if (this.vm && !this.isStarted) {
this.vm.start();
this.isStarted = true;
}
}
/**
* @zh 停止执行蓝图
* @en Stop blueprint execution
*/
stop(): void {
if (this.vm && this.isStarted) {
this.vm.stop();
this.isStarted = false;
}
}
/**
* @zh 更新蓝图
* @en Update blueprint
*/
tick(deltaTime: number): void {
if (this.vm && this.isStarted) {
this.vm.tick(deltaTime);
}
}
/**
* @zh 清理蓝图资源
* @en Cleanup blueprint resources
*/
cleanup(): void {
if (this.vm) {
if (this.isStarted) {
this.vm.stop();
}
this.vm = null;
this.isStarted = false;
}
component.vm = null;
component.isStarted = false;
}
}

View File

@@ -1,121 +1,86 @@
/**
* Blueprint Execution System - Manages blueprint lifecycle and execution
* 蓝图执行系统 - 管理蓝图生命周期和执行
* @zh 蓝图系统 - 处理所有带有 BlueprintComponent 的实体
* @en Blueprint System - Processes all entities with BlueprintComponent
*/
import type { Entity, IScene } from '@esengine/ecs-framework';
import {
IBlueprintComponent,
initializeBlueprintVM,
startBlueprint,
tickBlueprint,
cleanupBlueprint
} from './BlueprintComponent';
import { EntitySystem, Matcher, ECSSystem, type Entity, Time } from '@esengine/ecs-framework';
import { BlueprintComponent } from './BlueprintComponent';
import { registerAllComponentNodes } from '../registry';
/**
* Blueprint system interface for engine integration
* 用于引擎集成的蓝图系统接口
* @zh 蓝图执行系统
* @en Blueprint execution system
*
* @zh 自动处理所有带有 BlueprintComponent 的实体,管理蓝图的初始化、执行和清理
* @en Automatically processes all entities with BlueprintComponent, manages blueprint initialization, execution and cleanup
*
* @example
* ```typescript
* import { BlueprintSystem } from '@esengine/blueprint';
*
* // 添加到场景
* scene.addSystem(new BlueprintSystem());
*
* // 为实体添加蓝图
* const entity = scene.createEntity('Player');
* const blueprint = new BlueprintComponent();
* blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
* entity.addComponent(blueprint);
* ```
*/
export interface IBlueprintSystem {
/** Process entities with blueprint components (处理带有蓝图组件的实体) */
process(entities: IBlueprintEntity[], deltaTime: number): void;
@ECSSystem('BlueprintSystem')
export class BlueprintSystem extends EntitySystem {
private _componentsRegistered = false;
/** Called when entity is added to system (实体添加到系统时调用) */
onEntityAdded(entity: IBlueprintEntity): void;
constructor() {
super(Matcher.all(BlueprintComponent));
}
/** Called when entity is removed from system (实体从系统移除时调用) */
onEntityRemoved(entity: IBlueprintEntity): void;
}
/**
* Entity with blueprint component
* 带有蓝图组件的实体
*/
export interface IBlueprintEntity extends Entity {
/** Blueprint component data (蓝图组件数据) */
blueprintComponent: IBlueprintComponent;
}
/**
* Creates a blueprint execution system
* 创建蓝图执行系统
*/
export function createBlueprintSystem(scene: IScene): IBlueprintSystem {
return {
process(entities: IBlueprintEntity[], deltaTime: number): void {
for (const entity of entities) {
const component = entity.blueprintComponent;
// Skip if no blueprint asset loaded
// 如果没有加载蓝图资产则跳过
if (!component.blueprintAsset) {
continue;
}
// Initialize VM if needed
// 如果需要则初始化 VM
if (!component.vm) {
initializeBlueprintVM(component, entity, scene);
}
// Auto-start if enabled
// 如果启用则自动启动
if (component.autoStart && !component.isStarted) {
startBlueprint(component);
}
// Tick the blueprint
// 更新蓝图
tickBlueprint(component, deltaTime);
}
},
onEntityAdded(entity: IBlueprintEntity): void {
const component = entity.blueprintComponent;
if (component.blueprintAsset) {
initializeBlueprintVM(component, entity, scene);
if (component.autoStart) {
startBlueprint(component);
}
}
},
onEntityRemoved(entity: IBlueprintEntity): void {
cleanupBlueprint(entity.blueprintComponent);
/**
* @zh 系统初始化时注册所有组件节点
* @en Register all component nodes when system initializes
*/
protected override onInitialize(): void {
if (!this._componentsRegistered) {
registerAllComponentNodes();
this._componentsRegistered = true;
}
};
}
}
/**
* Utility to manually trigger blueprint events
* 手动触发蓝图事件的工具
*/
export function triggerBlueprintEvent(
entity: IBlueprintEntity,
eventType: string,
data?: Record<string, unknown>
): void {
const vm = entity.blueprintComponent.vm;
/**
* @zh 处理所有带有蓝图组件的实体
* @en Process all entities with blueprint components
*/
protected override process(entities: readonly Entity[]): void {
const dt = Time.deltaTime;
if (vm && entity.blueprintComponent.isStarted) {
vm.triggerEvent(eventType, data);
}
}
/**
* Utility to trigger custom events by name
* 按名称触发自定义事件的工具
*/
export function triggerCustomBlueprintEvent(
entity: IBlueprintEntity,
eventName: string,
data?: Record<string, unknown>
): void {
const vm = entity.blueprintComponent.vm;
if (vm && entity.blueprintComponent.isStarted) {
vm.triggerCustomEvent(eventName, data);
for (const entity of entities) {
const blueprint = entity.getComponent(BlueprintComponent);
if (!blueprint?.blueprintAsset) continue;
// 初始化 VM
if (!blueprint.vm) {
blueprint.initialize(entity, this.scene!);
}
// 自动启动
if (blueprint.autoStart && !blueprint.isStarted) {
blueprint.start();
}
// 每帧更新
blueprint.tick(dt);
}
}
/**
* @zh 实体移除时清理蓝图资源
* @en Cleanup blueprint resources when entity is removed
*/
protected override onRemoved(entity: Entity): void {
const blueprint = entity.getComponent(BlueprintComponent);
if (blueprint) {
blueprint.cleanup();
}
}
}

View File

@@ -3,9 +3,10 @@
* 执行上下文 - 蓝图执行的运行时上下文
*/
import type { Entity, IScene } from '@esengine/ecs-framework';
import type { Entity, IScene, Component } from '@esengine/ecs-framework';
import { BlueprintNode, BlueprintConnection } from '../types/nodes';
import { BlueprintAsset } from '../types/blueprint';
import { getRegisteredBlueprintComponents } from '../registry/BlueprintDecorators';
/**
* Result of node execution
@@ -72,6 +73,9 @@ export class ExecutionContext {
/** Global variables (shared) (全局变量,共享) */
private static _globalVariables: Map<string, unknown> = new Map();
/** Component class registry (组件类注册表) */
private static _componentRegistry: Map<string, new () => Component> = new Map();
/** Node output cache for current execution (当前执行的节点输出缓存) */
private _outputCache: Map<string, Record<string, unknown>> = new Map();
@@ -267,4 +271,49 @@ export class ExecutionContext {
static clearGlobalVariables(): void {
ExecutionContext._globalVariables.clear();
}
/**
* Get a component class by name
* 通过名称获取组件类
*
* @zh 首先检查 @BlueprintExpose 装饰的组件,然后检查手动注册的组件
* @en First checks @BlueprintExpose decorated components, then manually registered ones
*/
getComponentClass(typeName: string): (new () => Component) | undefined {
// First check registered blueprint components
const blueprintComponents = getRegisteredBlueprintComponents();
for (const [componentClass, metadata] of blueprintComponents) {
if (metadata.componentName === typeName ||
componentClass.name === typeName) {
return componentClass as new () => Component;
}
}
// Then check manual registry
return ExecutionContext._componentRegistry.get(typeName);
}
/**
* Register a component class for dynamic creation
* 注册组件类以支持动态创建
*/
static registerComponentClass(typeName: string, componentClass: new () => Component): void {
ExecutionContext._componentRegistry.set(typeName, componentClass);
}
/**
* Unregister a component class
* 取消注册组件类
*/
static unregisterComponentClass(typeName: string): void {
ExecutionContext._componentRegistry.delete(typeName);
}
/**
* Get all registered component classes
* 获取所有已注册的组件类
*/
static getRegisteredComponentClasses(): Map<string, new () => Component> {
return new Map(ExecutionContext._componentRegistry);
}
}

View File

@@ -87,10 +87,21 @@ export interface BlueprintAsset {
}
/**
* Creates an empty blueprint asset
* 创建空蓝图资产
* Creates an empty blueprint asset with default Event Begin Play node
* 创建带有默认 Event Begin Play 节点的空蓝图资产
*/
export function createEmptyBlueprint(name: string): BlueprintAsset {
export function createEmptyBlueprint(name: string, includeBeginPlay: boolean = true): BlueprintAsset {
const nodes: BlueprintNode[] = [];
if (includeBeginPlay) {
nodes.push({
id: 'node_beginplay_1',
type: 'EventBeginPlay',
position: { x: 100, y: 200 },
data: {}
});
}
return {
version: 1,
type: 'blueprint',
@@ -100,7 +111,7 @@ export function createEmptyBlueprint(name: string): BlueprintAsset {
modifiedAt: Date.now()
},
variables: [],
nodes: [],
nodes,
connections: []
};
}

View File

@@ -1,5 +1,33 @@
# @esengine/fsm
## 8.0.0
### Patch Changes
- Updated dependencies [[`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b)]:
- @esengine/blueprint@4.4.0
## 7.0.0
### Patch Changes
- Updated dependencies [[`c2acd14`](https://github.com/esengine/esengine/commit/c2acd14fce83af6cd116b3f2e40607229ccc3d6e)]:
- @esengine/blueprint@4.3.0
## 6.0.0
### Patch Changes
- Updated dependencies [[`2e84942`](https://github.com/esengine/esengine/commit/2e84942ea14c5326620398add05840fa8bea16f8)]:
- @esengine/blueprint@4.2.0
## 5.0.0
### Patch Changes
- Updated dependencies [[`caf3be7`](https://github.com/esengine/esengine/commit/caf3be72cdcc730492c63abe5f1715893f3579ac)]:
- @esengine/blueprint@4.1.0
## 4.0.1
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/fsm",
"version": "4.0.1",
"version": "8.0.0",
"description": "Finite State Machine for ECS Framework / ECS 框架的有限状态机",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1,5 +1,33 @@
# @esengine/network
## 9.0.0
### Patch Changes
- Updated dependencies [[`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b)]:
- @esengine/blueprint@4.4.0
## 8.0.0
### Patch Changes
- Updated dependencies [[`c2acd14`](https://github.com/esengine/esengine/commit/c2acd14fce83af6cd116b3f2e40607229ccc3d6e)]:
- @esengine/blueprint@4.3.0
## 7.0.0
### Patch Changes
- Updated dependencies [[`2e84942`](https://github.com/esengine/esengine/commit/2e84942ea14c5326620398add05840fa8bea16f8)]:
- @esengine/blueprint@4.2.0
## 6.0.0
### Patch Changes
- Updated dependencies [[`caf3be7`](https://github.com/esengine/esengine/commit/caf3be72cdcc730492c63abe5f1715893f3579ac)]:
- @esengine/blueprint@4.1.0
## 5.0.3
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/network",
"version": "5.0.3",
"version": "9.0.0",
"description": "Network synchronization for multiplayer games",
"esengine": {
"plugin": true,

Some files were not shown because too many files have changed in this diff Show More