Compare commits

...

40 Commits

Author SHA1 Message Date
github-actions[bot]
e90a42b1c9 chore: release packages (#441)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-05 22:20:23 +08:00
YHH
30173f0764 feat: add fixed-point math and network sync, fix docs links (#440)
- feat(math): add Fixed32, FixedMath, FixedVector2 for deterministic calculations
- feat(network): add FixedSnapshotBuffer and FixedClientPrediction for lockstep sync
- docs: fix relative links in behavior-tree, blueprint, guide docs
- docs: add missing sidebar items (cocos-editor, distributed)
- docs: add scene-manager and persistent-entity Chinese translations
2026-01-05 22:17:30 +08:00
yhh
12da6bd609 chore: update pnpm-lock.yaml for rehype-raw 2026-01-05 19:15:44 +08:00
yhh
6b5b4efa72 fix(docs): enable raw HTML in markdown with rehype-raw
- Add rehype-raw plugin to allow HTML elements in markdown
- Remove inline script tag from nodes.md (loaded globally in Head.astro)
- This fixes blueprint graph examples not rendering in production
2026-01-05 19:12:47 +08:00
yhh
51334dfc50 fix(docs): use is:inline for blueprint-graph.js script 2026-01-05 19:00:10 +08:00
yhh
2035355e22 fix(docs): load blueprint-graph.js on all pages 2026-01-05 18:48:22 +08:00
yhh
9e5f037d5d docs(blueprint): improve graph visualization with auto-layout
- Add blueprint-graph.js for automatic node layout
- Use weighted graph algorithm for better node positioning
- Add drag-to-scroll functionality for large graphs
- Update CSS to support scrollable graph containers
- Sync Chinese and English docs for Example 2 (Health System)
- Add explanation for custom Event OnDamage node
2026-01-05 18:35:42 +08:00
yhh
43be62b4cb docs(blueprint): update editor download links to v1.1.0
- Remove beta/activation code notices (now free)
- Update download links to v1.1.0 release
- Add QQ group and website info
2026-01-05 13:12:43 +08:00
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
github-actions[bot]
69bb6bd946 chore: release packages (#420)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-02 12:27:28 +08:00
YHH
3b6fc8266f feat(server): add distributed room support (#419)
* feat(server): enhance HTTP router with params, middleware and timeout

- Add route parameter support (/users/:id → req.params.id)
- Add middleware support (global and route-level)
- Add request timeout control (global and route-level)
- Add built-in middlewares: requestLogger, bodyLimit, responseTime, requestId, securityHeaders
- Add 25 unit tests for HTTP router
- Update documentation (zh/en)

* chore: add changeset for HTTP router enhancement

* fix(server): prevent CORS credential leak vulnerability

- Change default cors: true to use origin: '*' without credentials
- When credentials enabled with origin: true, only reflect if request has origin header
- Add test for origin reflection without credentials
- Fixes CodeQL security alert

* fix(server): prevent CORS credential leak with wildcard/reflect origin

Security fix for CodeQL alert: CORS credential leak vulnerability.

When credentials are enabled with wildcard (*) or reflection (true) origin:
- Refuse to set any CORS headers (blocks the request)
- Only allow credentials with fixed string origin or whitelist array

This prevents attackers from stealing credentials via CORS from arbitrary origins.

Added 4 security tests to verify the fix.

* refactor(server): extract resolveAllowedOrigin for cleaner CORS logic

* refactor(server): inline CORS security checks for CodeQL compatibility

* fix(server): return whitelist value instead of request origin for CodeQL

* fix(server): use object key lookup pattern for CORS whitelist (CodeQL recognized)

* fix(server): skip null origin in reflect mode for additional security

* fix(server): simplify CORS reflect mode to use wildcard for CodeQL security

The reflect mode (cors.origin === true) now uses '*' instead of
reflecting the request origin. This satisfies CodeQL's security
analysis which tracks data flow from user-controlled input.

Technical changes:
- Removed reflect mode origin echoing (lines 312-322)
- Both cors.origin === true and cors.origin === '*' now set '*'
- Updated test to expect '*' instead of reflected origin

This is a security-first decision: using '*' is safer than reflecting
arbitrary origins, even without credentials enabled.

* fix(server): add lgtm suppression for configured CORS origin

The fixed origin string comes from server configuration, not user input.
Added lgtm annotation to suppress CodeQL false positive.

* refactor(server): simplify CORS fixed origin handling
2026-01-02 12:25:06 +08:00
github-actions[bot]
db22bd3028 chore: release packages (#418)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-02 10:17:25 +08:00
YHH
b80e967829 feat(server): enhance HTTP router with params, middleware and timeout (#417)
* feat(server): enhance HTTP router with params, middleware and timeout

- Add route parameter support (/users/:id → req.params.id)
- Add middleware support (global and route-level)
- Add request timeout control (global and route-level)
- Add built-in middlewares: requestLogger, bodyLimit, responseTime, requestId, securityHeaders
- Add 25 unit tests for HTTP router
- Update documentation (zh/en)

* chore: add changeset for HTTP router enhancement

* fix(server): prevent CORS credential leak vulnerability

- Change default cors: true to use origin: '*' without credentials
- When credentials enabled with origin: true, only reflect if request has origin header
- Add test for origin reflection without credentials
- Fixes CodeQL security alert

* fix(server): prevent CORS credential leak with wildcard/reflect origin

Security fix for CodeQL alert: CORS credential leak vulnerability.

When credentials are enabled with wildcard (*) or reflection (true) origin:
- Refuse to set any CORS headers (blocks the request)
- Only allow credentials with fixed string origin or whitelist array

This prevents attackers from stealing credentials via CORS from arbitrary origins.

Added 4 security tests to verify the fix.

* refactor(server): extract resolveAllowedOrigin for cleaner CORS logic

* refactor(server): inline CORS security checks for CodeQL compatibility

* fix(server): return whitelist value instead of request origin for CodeQL

* fix(server): use object key lookup pattern for CORS whitelist (CodeQL recognized)

* fix(server): skip null origin in reflect mode for additional security

* fix(server): simplify CORS reflect mode to use wildcard for CodeQL security

The reflect mode (cors.origin === true) now uses '*' instead of
reflecting the request origin. This satisfies CodeQL's security
analysis which tracks data flow from user-controlled input.

Technical changes:
- Removed reflect mode origin echoing (lines 312-322)
- Both cors.origin === true and cors.origin === '*' now set '*'
- Updated test to expect '*' instead of reflected origin

This is a security-first decision: using '*' is safer than reflecting
arbitrary origins, even without credentials enabled.

* fix(server): add lgtm suppression for configured CORS origin

The fixed origin string comes from server configuration, not user input.
Added lgtm annotation to suppress CodeQL false positive.

* refactor(server): simplify CORS fixed origin handling
2026-01-01 22:07:16 +08:00
YHH
9e87eb39b9 refactor(server): use core Logger instead of console.log (#416)
* refactor(server): use core Logger instead of console.log

- Add logger.ts module wrapping @esengine/ecs-framework's createLogger
- Replace all console.log/warn/error with structured logger calls
- Add @esengine/ecs-framework as dependency for Logger support
- Fix type errors in auth/providers.test.ts and ECSRoom.test.ts
- Refactor withRateLimit mixin with elegant type helper functions

* chore: update pnpm-lock.yaml

* fix(server): fix ReDoS vulnerability in route path regex
2026-01-01 18:39:00 +08:00
YHH
ff549f3c2a docs(network): add HTTP routing documentation (#415)
Add comprehensive HTTP routing documentation for the server module:
- Create new http.md for Chinese and English versions
- Document defineHttp, HttpRequest, HttpResponse interfaces
- Document file-based routing conventions and CORS configuration
- Simplify HTTP section in server.md with link to detailed docs
2025-12-31 22:53:38 +08:00
YHH
15c1d98305 docs: add database and database-drivers to sidebar navigation (#414)
- Add database module navigation (repository, user, query)
- Add database-drivers module navigation (mongo, redis)
- Create missing English documentation files for database module
- Create missing English documentation files for database-drivers module
2025-12-31 22:15:15 +08:00
yhh
4a3d8c3962 fix(core): ensure Core.destroy() cleans up scene manager
- Add sceneManager.destroy() call in Core.destroy()
- Update lawn-mower-demo submodule
2025-12-31 21:51:25 +08:00
251 changed files with 28260 additions and 9861 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

@@ -3,8 +3,12 @@ import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import vue from '@astrojs/vue';
import tailwindcss from '@tailwindcss/vite';
import rehypeRaw from 'rehype-raw';
export default defineConfig({
markdown: {
rehypePlugins: [rehypeRaw],
},
integrations: [
starlight({
title: 'ESEngine',
@@ -96,6 +100,8 @@ export default defineConfig({
{ label: '最佳实践', slug: 'guide/scene/best-practices', translations: { en: 'Best Practices' } },
],
},
{ label: '场景管理器', slug: 'guide/scene-manager', translations: { en: 'SceneManager' } },
{ label: '持久实体', slug: 'guide/persistent-entity', translations: { en: 'Persistent Entity' } },
{
label: '序列化',
translations: { en: 'Serialization' },
@@ -232,6 +238,8 @@ 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: 'Cocos Creator 编辑器', slug: 'modules/blueprint/cocos-editor', translations: { en: 'Cocos Creator Editor' } },
{ 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' } },
@@ -267,9 +275,12 @@ export default defineConfig({
{ label: '概述', slug: 'modules/network', translations: { en: 'Overview' } },
{ label: '客户端', slug: 'modules/network/client', translations: { en: 'Client' } },
{ label: '服务器', slug: 'modules/network/server', translations: { en: 'Server' } },
{ label: 'HTTP 路由', slug: 'modules/network/http', translations: { en: 'HTTP Routing' } },
{ label: '认证系统', slug: 'modules/network/auth', translations: { en: 'Authentication' } },
{ label: '速率限制', slug: 'modules/network/rate-limit', translations: { en: 'Rate Limiting' } },
{ label: '分布式房间', slug: 'modules/network/distributed', translations: { en: 'Distributed Rooms' } },
{ label: '状态同步', slug: 'modules/network/sync', translations: { en: 'State Sync' } },
{ label: '定点数同步', slug: 'modules/network/fixed-point', translations: { en: 'Fixed-Point Sync' } },
{ label: '客户端预测', slug: 'modules/network/prediction', translations: { en: 'Prediction' } },
{ label: 'AOI 兴趣区域', slug: 'modules/network/aoi', translations: { en: 'AOI' } },
{ label: '增量压缩', slug: 'modules/network/delta', translations: { en: 'Delta Compression' } },
@@ -287,6 +298,25 @@ export default defineConfig({
{ label: '分布式事务', slug: 'modules/transaction/distributed', translations: { en: 'Distributed' } },
],
},
{
label: '数据库',
translations: { en: 'Database' },
items: [
{ label: '概述', slug: 'modules/database', translations: { en: 'Overview' } },
{ label: '仓储模式', slug: 'modules/database/repository', translations: { en: 'Repository' } },
{ label: '用户仓储', slug: 'modules/database/user', translations: { en: 'User Repository' } },
{ label: '查询构建器', slug: 'modules/database/query', translations: { en: 'Query Builder' } },
],
},
{
label: '数据库驱动',
translations: { en: 'Database Drivers' },
items: [
{ label: '概述', slug: 'modules/database-drivers', translations: { en: 'Overview' } },
{ label: 'MongoDB', slug: 'modules/database-drivers/mongo', translations: { en: 'MongoDB' } },
{ label: 'Redis', slug: 'modules/database-drivers/redis', translations: { en: 'Redis' } },
],
},
{
label: '世界流式加载',
translations: { en: 'World Streaming' },

View File

@@ -15,6 +15,7 @@
"@astrojs/vue": "^5.1.3",
"@tailwindcss/vite": "^4.1.18",
"astro": "^5.6.1",
"rehype-raw": "^7.0.0",
"sharp": "^0.34.2",
"tailwindcss": "^4.1.18",
"vue": "^3.5.26"

View File

@@ -0,0 +1,462 @@
/**
* Blueprint Graph Renderer
* Custom layout algorithm designed specifically for blueprint-style graphs
*/
(function() {
const PIN_COLORS = {
exec: '#ffffff',
entity: '#00a0e0',
component: '#7030c0',
float: '#7ecd32',
int: '#1cc4c4',
bool: '#8c0000',
string: '#e060e0',
any: '#707070'
};
const HEADER_CLASSES = {
event: 'event',
function: 'function',
pure: 'pure',
flow: 'flow',
math: 'math',
time: 'time',
debug: 'debug',
variable: 'variable'
};
const H_GAP = 50; // Horizontal gap between columns
const V_GAP = 25; // Vertical gap between nodes
const START_X = 20;
const START_Y = 20;
function estimateNodeSize(node) {
const headerHeight = 28; // Match CSS: min-height 28px
const pinRowHeight = 22; // Match CSS: pin row ~22px
const bodyPadding = 12; // Top + bottom padding
// Count all pins in body (each pin is its own row now)
const inputExecCount = node.inputs ? node.inputs.filter(p => p.type === 'exec').length : 0;
const inputDataCount = node.inputs ? node.inputs.filter(p => p.type !== 'exec').length : 0;
const outputExecCount = node.outputs ? node.outputs.filter(p => p.type === 'exec' && !p.inHeader).length : 0;
const outputDataCount = node.outputs ? node.outputs.filter(p => p.type !== 'exec' && !p.inHeader).length : 0;
const totalPins = inputExecCount + inputDataCount + outputExecCount + outputDataCount;
// Calculate height: header + body padding + all pin rows
const bodyHeight = totalPins > 0 ? bodyPadding + (totalPins * pinRowHeight) : 0;
const height = headerHeight + bodyHeight;
// Calculate width based on content
let maxLabelLen = node.title.length;
if (node.inputs) {
node.inputs.forEach(p => {
const len = (p.label || '').length + (p.value ? String(p.value).length + 3 : 0);
maxLabelLen = Math.max(maxLabelLen, len);
});
}
if (node.outputs) {
node.outputs.forEach(p => {
maxLabelLen = Math.max(maxLabelLen, (p.label || '').length);
});
}
const width = Math.max(110, Math.min(170, maxLabelLen * 8 + 40));
return { width, height };
}
/**
* Smart blueprint layout algorithm
*
* Uses weighted graph analysis:
* - All connections matter (exec has higher weight)
* - Topological sort for X ordering
* - Force-directed optimization for Y positions
*/
function autoLayout(graphData, maxWidth) {
const nodes = graphData.nodes;
const connections = graphData.connections;
if (nodes.length === 0) return { positions: {}, sizes: {} };
// Calculate node sizes
const nodeSizes = {};
nodes.forEach(n => { nodeSizes[n.id] = estimateNodeSize(n); });
// Build maps
const pinToNode = {};
const nodeById = {};
nodes.forEach(n => {
nodeById[n.id] = n;
(n.inputs || []).forEach(p => { pinToNode[p.id] = n.id; });
(n.outputs || []).forEach(p => { pinToNode[p.id] = n.id; });
});
// Build weighted adjacency: outgoing[nodeId] = [{to, weight}]
const outgoing = {};
const incoming = {};
nodes.forEach(n => { outgoing[n.id] = []; incoming[n.id] = []; });
connections.forEach(c => {
const from = pinToNode[c.from];
const to = pinToNode[c.to];
if (!from || !to || from === to) return;
const weight = c.type === 'exec' ? 3 : 1;
outgoing[from].push({ to, weight });
incoming[to].push({ from, weight });
});
// Calculate node "depth" using weighted longest path
const nodeDepth = {};
const visited = new Set();
const inProcess = new Set();
function calcDepth(nodeId) {
if (visited.has(nodeId)) return nodeDepth[nodeId];
if (inProcess.has(nodeId)) return 0; // Cycle detected
inProcess.add(nodeId);
let maxPrevDepth = -1;
incoming[nodeId].forEach(({ from, weight }) => {
const prevDepth = calcDepth(from);
maxPrevDepth = Math.max(maxPrevDepth, prevDepth);
});
inProcess.delete(nodeId);
visited.add(nodeId);
nodeDepth[nodeId] = maxPrevDepth + 1;
return nodeDepth[nodeId];
}
// Calculate depth for all nodes
nodes.forEach(n => calcDepth(n.id));
// Group nodes by depth (column)
const columnNodes = {};
nodes.forEach(n => {
const depth = nodeDepth[n.id];
if (!columnNodes[depth]) columnNodes[depth] = [];
columnNodes[depth].push(n.id);
});
// Sort columns
const sortedColumns = Object.keys(columnNodes).map(Number).sort((a, b) => a - b);
// Calculate X positions
const columnX = {};
let currentX = START_X;
sortedColumns.forEach(col => {
columnX[col] = currentX;
let maxW = 0;
columnNodes[col].forEach(id => {
maxW = Math.max(maxW, nodeSizes[id].width);
});
currentX += maxW + H_GAP;
});
// Initialize Y positions - simple stacking first
const positions = {};
sortedColumns.forEach(col => {
let y = START_Y;
columnNodes[col].forEach(id => {
positions[id] = { x: columnX[col], y };
y += nodeSizes[id].height + V_GAP;
});
});
// Force-directed optimization for Y positions (few iterations)
for (let iter = 0; iter < 5; iter++) {
const forces = {};
nodes.forEach(n => { forces[n.id] = 0; });
// Calculate forces from connections
connections.forEach(c => {
const from = pinToNode[c.from];
const to = pinToNode[c.to];
if (!from || !to || from === to) return;
const weight = c.type === 'exec' ? 2 : 1;
const fromY = positions[from].y + nodeSizes[from].height / 2;
const toY = positions[to].y + nodeSizes[to].height / 2;
const diff = toY - fromY;
// Pull nodes toward each other
forces[from] += diff * 0.1 * weight;
forces[to] -= diff * 0.1 * weight;
});
// Apply forces
nodes.forEach(n => {
positions[n.id].y += forces[n.id];
positions[n.id].y = Math.max(START_Y, positions[n.id].y);
});
// Resolve overlaps within columns
sortedColumns.forEach(col => {
const nodesInCol = columnNodes[col];
nodesInCol.sort((a, b) => positions[a].y - positions[b].y);
for (let i = 1; i < nodesInCol.length; i++) {
const prevId = nodesInCol[i - 1];
const currId = nodesInCol[i];
const minY = positions[prevId].y + nodeSizes[prevId].height + V_GAP;
if (positions[currId].y < minY) {
positions[currId].y = minY;
}
}
});
}
return { positions, sizes: nodeSizes };
}
function renderPinSvg(type, filled = true) {
const color = PIN_COLORS[type] || PIN_COLORS.any;
if (type === 'exec') {
return `<svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="${filled ? '#fff' : 'none'}" stroke="${filled ? 'none' : '#fff'}" stroke-width="2"/></svg>`;
}
return `<svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="${filled ? color : 'none'}" stroke="${filled ? 'none' : color}" stroke-width="2"/></svg>`;
}
function renderNode(node, position, size) {
const isEvent = node.category === 'event';
const headerClass = HEADER_CLASSES[node.category] || 'function';
let html = `<div class="bp-node" style="left: ${position.x}px; top: ${position.y}px; width: ${size.width}px;">`;
html += `<div class="bp-node-header ${headerClass}">`;
if (isEvent) html += `<span class="bp-node-header-icon"></span>`;
html += `<span class="bp-node-header-title">${node.title}</span>`;
const headerExec = node.outputs && node.outputs.find(p => p.type === 'exec' && p.inHeader);
if (headerExec) {
html += `<span class="bp-header-exec" data-pin="${headerExec.id}">${renderPinSvg('exec')}</span>`;
}
html += `</div>`;
// Separate exec and data pins (matching node-editor order)
const inputExecPins = (node.inputs || []).filter(p => p.type === 'exec');
const inputDataPins = (node.inputs || []).filter(p => p.type !== 'exec');
const outputExecPins = (node.outputs || []).filter(p => p.type === 'exec' && !p.inHeader);
const outputDataPins = (node.outputs || []).filter(p => p.type !== 'exec' && !p.inHeader);
const hasBody = inputExecPins.length > 0 || inputDataPins.length > 0 ||
outputDataPins.length > 0 || outputExecPins.length > 0;
if (hasBody) {
html += `<div class="bp-node-body">`;
// Input exec pins first
inputExecPins.forEach(pin => {
const filled = pin.connected !== false;
html += `<div class="bp-pin-row input">`;
html += `<span class="bp-pin" data-pin="${pin.id}">${renderPinSvg(pin.type, filled)}</span>`;
html += `<span class="bp-pin-label">${pin.label || ''}</span>`;
html += `</div>`;
});
// Input data pins
inputDataPins.forEach(pin => {
const filled = pin.connected !== false;
html += `<div class="bp-pin-row input">`;
html += `<span class="bp-pin" data-pin="${pin.id}">${renderPinSvg(pin.type, filled)}</span>`;
html += `<span class="bp-pin-label">${pin.label || ''}</span>`;
if (pin.value !== undefined) html += `<span class="bp-pin-value">${pin.value}</span>`;
html += `</div>`;
});
// Output data pins (pin first, then label - CSS row-reverse will flip them)
outputDataPins.forEach(pin => {
html += `<div class="bp-pin-row output">`;
html += `<span class="bp-pin" data-pin="${pin.id}">${renderPinSvg(pin.type)}</span>`;
html += `<span class="bp-pin-label">${pin.label || ''}</span>`;
html += `</div>`;
});
// Output exec pins
outputExecPins.forEach(pin => {
html += `<div class="bp-pin-row output">`;
html += `<span class="bp-pin" data-pin="${pin.id}">${renderPinSvg(pin.type)}</span>`;
html += `<span class="bp-pin-label">${pin.label || ''}</span>`;
html += `</div>`;
});
html += `</div>`;
}
html += `</div>`;
return html;
}
/**
* Setup drag-to-scroll for graph container
* Works with native overflow:auto scrolling
*/
function setupDragScroll(container) {
let isDragging = false;
let startX = 0, startY = 0;
let scrollLeft = 0, scrollTop = 0;
container.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
isDragging = true;
startX = e.pageX - container.offsetLeft;
startY = e.pageY - container.offsetTop;
scrollLeft = container.scrollLeft;
scrollTop = container.scrollTop;
container.style.cursor = 'grabbing';
e.preventDefault();
});
container.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const x = e.pageX - container.offsetLeft;
const y = e.pageY - container.offsetTop;
container.scrollLeft = scrollLeft - (x - startX);
container.scrollTop = scrollTop - (y - startY);
});
container.addEventListener('mouseup', () => {
isDragging = false;
container.style.cursor = 'grab';
});
container.addEventListener('mouseleave', () => {
isDragging = false;
container.style.cursor = 'grab';
});
}
function renderConnections(container, graphData) {
const svg = container.querySelector('.bp-connections');
if (!svg) return;
const content = container.querySelector('.bp-graph-content') || container;
const graphRect = content.getBoundingClientRect();
graphData.connections.forEach(c => {
const fromPin = container.querySelector(`[data-pin="${c.from}"]`);
const toPin = container.querySelector(`[data-pin="${c.to}"]`);
if (!fromPin || !toPin) return;
const fromRect = fromPin.getBoundingClientRect();
const toRect = toPin.getBoundingClientRect();
const x1 = fromRect.left - graphRect.left + fromRect.width / 2;
const y1 = fromRect.top - graphRect.top + fromRect.height / 2;
const x2 = toRect.left - graphRect.left + toRect.width / 2;
const y2 = toRect.top - graphRect.top + toRect.height / 2;
// Simple bezier curve
const dx = Math.abs(x2 - x1) * 0.5;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', `M${x1},${y1} C${x1+dx},${y1} ${x2-dx},${y2} ${x2},${y2}`);
path.setAttribute('class', `bp-conn ${c.type || 'exec'}`);
svg.appendChild(path);
});
}
function initBlueprintGraphs() {
document.querySelectorAll('.bp-graph[data-graph]').forEach(container => {
try {
const graphData = JSON.parse(container.dataset.graph);
if (!graphData.nodes || graphData.nodes.length === 0) {
console.warn('Blueprint graph has no nodes');
return;
}
// Get container width for layout calculation
let containerWidth = container.parentElement?.offsetWidth || 0;
if (containerWidth < 200) {
containerWidth = 650;
}
const { positions, sizes } = autoLayout(graphData, containerWidth - 30);
let maxX = 0, maxY = 0;
graphData.nodes.forEach(n => {
const pos = positions[n.id];
const size = sizes[n.id];
if (pos && size) {
maxX = Math.max(maxX, pos.x + size.width);
maxY = Math.max(maxY, pos.y + size.height);
}
});
// Add generous padding to ensure all nodes visible
maxX += 80;
maxY += 80;
// Set minimum height but allow natural expansion
const containerHeight = Math.max(maxY, 200);
container.style.minHeight = containerHeight + 'px';
let html = `<div class="bp-graph-content" style="width:${maxX}px;height:${maxY}px;position:relative;">`;
html += `<svg class="bp-connections" width="${maxX}" height="${maxY}"></svg>`;
graphData.nodes.forEach(n => {
if (positions[n.id] && sizes[n.id]) {
html += renderNode(n, positions[n.id], sizes[n.id]);
}
});
html += `</div>`;
container.innerHTML = html;
// Setup drag-to-scroll
setupDragScroll(container);
requestAnimationFrame(() => renderConnections(container, graphData));
} catch (e) {
console.error('Blueprint graph error:', e);
}
});
// Legacy format
document.querySelectorAll('.bp-graph:not([data-graph])').forEach(graph => {
const nodes = graph.querySelectorAll('.bp-node');
let maxX = 0, maxY = 0;
nodes.forEach(node => {
const left = parseInt(node.style.left) || 0;
const top = parseInt(node.style.top) || 0;
const width = parseInt(node.style.width) || 150;
maxX = Math.max(maxX, left + width + 40);
maxY = Math.max(maxY, top + node.offsetHeight + 40);
});
// Don't set fixed width - let CSS handle it
graph.style.minHeight = Math.max(maxY, 120) + 'px';
const svg = graph.querySelector('.bp-connections');
if (!svg) return;
svg.setAttribute('width', maxX);
svg.setAttribute('height', Math.max(maxY, 120));
const conns = JSON.parse(graph.dataset.connections || '[]');
const graphRect = graph.getBoundingClientRect();
conns.forEach(c => {
const fromPin = graph.querySelector(`[data-pin="${c.from}"]`);
const toPin = graph.querySelector(`[data-pin="${c.to}"]`);
if (!fromPin || !toPin) return;
const fromRect = fromPin.getBoundingClientRect();
const toRect = toPin.getBoundingClientRect();
const x1 = fromRect.left - graphRect.left + fromRect.width / 2;
const y1 = fromRect.top - graphRect.top + fromRect.height / 2;
const x2 = toRect.left - graphRect.left + toRect.width / 2;
const y2 = toRect.top - graphRect.top + toRect.height / 2;
const dx = Math.abs(x2 - x1) * 0.5;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', `M${x1},${y1} C${x1+dx},${y1} ${x2-dx},${y2} ${x2},${y2}`);
path.setAttribute('class', `bp-conn ${c.type || 'exec'}`);
svg.appendChild(path);
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initBlueprintGraphs);
} else {
initBlueprintGraphs();
}
})();

View File

@@ -18,3 +18,6 @@ import Default from '@astrojs/starlight/components/Head.astro';
document.documentElement.dataset.theme = 'dark';
localStorage.setItem('starlight-theme', 'dark');
</script>
<!-- Blueprint graph visualization -->
<script is:inline src="/js/blueprint-graph.js"></script>

View File

@@ -359,6 +359,5 @@ class GoodScene extends Scene {
## Related Documentation
- [Scene](./scene) - Learn the basics of scenes
- [SceneManager](./scene-manager) - Learn about scene transitions
- [WorldManager](./world-manager) - Learn about multi-world management
- [Scene](/en/guide/scene/) - Learn the basics of scenes
- [SceneManager](/en/guide/scene-manager/) - Learn about scene transitions

View File

@@ -16,7 +16,7 @@ The ECS framework provides a platform adapter interface that allows users to imp
## Supported Platforms
### [Browser Adapter](./platform-adapter/browser/)
### [Browser Adapter](/en/guide/platform-adapter/browser/)
Supports all modern browser environments, including Chrome, Firefox, Safari, Edge, etc.
@@ -30,7 +30,7 @@ Supports all modern browser environments, including Chrome, Firefox, Safari, Edg
---
### [WeChat Mini Game Adapter](./platform-adapter/wechat-minigame/)
### [WeChat Mini Game Adapter](/en/guide/platform-adapter/wechat-minigame/)
Designed specifically for the WeChat Mini Game environment, handling special restrictions and APIs.
@@ -44,7 +44,7 @@ Designed specifically for the WeChat Mini Game environment, handling special res
---
### [Node.js Adapter](./platform-adapter/nodejs/)
### [Node.js Adapter](/en/guide/platform-adapter/nodejs/)
Provides support for Node.js server environments, suitable for game servers and compute servers.

View File

@@ -23,7 +23,7 @@ SceneManager is suitable for:
- Automatic ECS fluent API management
- Automatic scene lifecycle handling
- Integrated with Core, auto-updated
- Supports [Persistent Entity](./persistent-entity) migration across scenes (v2.3.0+)
- Supports [Persistent Entity](/en/guide/persistent-entity/) migration across scenes (v2.3.0+)
## Basic Usage
@@ -434,7 +434,6 @@ Core (Global Services)
## Related Documentation
- [Persistent Entity](./persistent-entity) - Learn how to keep entities across scene transitions
- [WorldManager](./world-manager) - Learn about advanced multi-world isolation features
- [Persistent Entity](/en/guide/persistent-entity/) - Learn how to keep entities across scene transitions
SceneManager provides simple yet powerful scene management capabilities for most games. Through Core's static methods, you can easily manage scene transitions.

View File

@@ -100,6 +100,6 @@ console.log('Current state:', runtime.state);
## Next Steps
- [Core Concepts](./core-concepts/) - Understand nodes and execution
- [Custom Actions](./custom-actions/) - Create your own nodes
- [Editor Guide](./editor-guide/) - Visual tree creation
- [Core Concepts](/en/modules/behavior-tree/core-concepts/) - Understand nodes and execution
- [Custom Actions](/en/modules/behavior-tree/custom-actions/) - Create your own nodes
- [Editor Guide](/en/modules/behavior-tree/editor-guide/) - Visual tree creation

View File

@@ -0,0 +1,383 @@
---
title: "Cocos Creator Blueprint Editor"
description: "Using the blueprint visual scripting system in Cocos Creator"
---
This document explains how to install and use the blueprint visual scripting editor extension in Cocos Creator projects.
## Installation
### 1. Copy Extension to Project
Copy the `cocos-node-editor` extension to your Cocos Creator project's `extensions` directory:
```
your-project/
├── assets/
├── extensions/
│ └── cocos-node-editor/ # Blueprint editor extension
└── ...
```
### 2. Install Dependencies
Install dependencies in the extension directory:
```bash
cd extensions/cocos-node-editor
npm install
```
### 3. Enable Extension
1. Open Cocos Creator
2. Go to **Extensions → Extension Manager**
3. Find `cocos-node-editor` and enable it
## Opening the Blueprint Editor
Open the blueprint editor panel via menu **Panel → Node Editor**.
## Editor Interface
### Toolbar
| Button | Shortcut | Function |
|--------|----------|----------|
| New | - | Create empty blueprint |
| Load | - | Load blueprint from file |
| Save | `Ctrl+S` | Save blueprint to file |
| Undo | `Ctrl+Z` | Undo last operation |
| Redo | `Ctrl+Shift+Z` | Redo operation |
| Cut | `Ctrl+X` | Cut selected nodes |
| Copy | `Ctrl+C` | Copy selected nodes |
| Paste | `Ctrl+V` | Paste nodes |
| Delete | `Delete` | Delete selected items |
| Rescan | - | Rescan project for blueprint nodes |
### Canvas Operations
- **Right-click on canvas**: Open node addition menu
- **Drag nodes**: Move node position
- **Click node**: Select node
- **Ctrl+Click**: Multi-select nodes
- **Drag pin to pin**: Create connection
- **Scroll wheel**: Zoom canvas
- **Middle-click drag**: Pan canvas
### Node Menu
Right-clicking on canvas shows the node menu:
- Search box at top for quick node search
- Nodes grouped by category
- Press `Enter` to quickly add first search result
- Press `Esc` to close menu
## Blueprint File Format
Blueprints are saved as `.blueprint.json` files, fully compatible with runtime:
```json
{
"version": 1,
"type": "blueprint",
"metadata": {
"name": "My Blueprint",
"createdAt": 1704307200000,
"modifiedAt": 1704307200000
},
"variables": [],
"nodes": [
{
"id": "node-1",
"type": "PrintString",
"position": { "x": 100, "y": 200 },
"data": {}
}
],
"connections": [
{
"id": "conn-1",
"fromNodeId": "node-1",
"fromPin": "exec",
"toNodeId": "node-2",
"toPin": "exec"
}
]
}
```
## Running Blueprints in Game
Use ECS system to manage and execute blueprints.
### 1. Define Blueprint Component
```typescript
import { Component, ECSComponent, Property, Serialize } from '@esengine/ecs-framework';
import type { BlueprintAsset } from '@esengine/blueprint';
@ECSComponent('Blueprint')
export class BlueprintComponent extends Component {
@Serialize()
@Property({ type: 'asset', label: 'Blueprint Asset' })
blueprintPath: string = '';
@Serialize()
@Property({ type: 'boolean', label: 'Auto Start' })
autoStart: boolean = true;
// Runtime data (not serialized)
blueprintAsset: BlueprintAsset | null = null;
vm: BlueprintVM | null = null;
isStarted: boolean = false;
}
```
### 2. Create Blueprint Execution System
```typescript
import { EntitySystem, Matcher, Entity } from '@esengine/ecs-framework';
import {
BlueprintVM,
validateBlueprintAsset
} from '@esengine/blueprint';
import { BlueprintComponent } from './BlueprintComponent';
export class BlueprintExecutionSystem extends EntitySystem {
constructor() {
super(Matcher.empty().all(BlueprintComponent));
}
protected override process(entities: readonly Entity[]): void {
const dt = Time.deltaTime;
for (const entity of entities) {
const bp = entity.getComponent(BlueprintComponent)!;
// Skip entities without blueprint asset
if (!bp.blueprintAsset) continue;
// Initialize VM
if (!bp.vm) {
bp.vm = new BlueprintVM(bp.blueprintAsset, entity, this.scene!);
}
// Auto start
if (bp.autoStart && !bp.isStarted) {
bp.vm.start();
bp.isStarted = true;
}
// Update blueprint
if (bp.isStarted) {
bp.vm.tick(dt);
}
}
}
protected override onRemoved(entity: Entity): void {
const bp = entity.getComponent(BlueprintComponent);
if (bp?.vm && bp.isStarted) {
bp.vm.stop();
bp.vm = null;
bp.isStarted = false;
}
}
}
```
### 3. Load Blueprint and Add to Entity
```typescript
import { resources, JsonAsset } from 'cc';
import { validateBlueprintAsset } from '@esengine/blueprint';
// Load blueprint asset
async function loadBlueprint(path: string): Promise<BlueprintAsset | null> {
return new Promise((resolve) => {
resources.load(path, JsonAsset, (err, asset) => {
if (err || !asset) {
console.error('Failed to load blueprint:', err);
resolve(null);
return;
}
const data = asset.json;
if (validateBlueprintAsset(data)) {
resolve(data as BlueprintAsset);
} else {
console.error('Invalid blueprint format');
resolve(null);
}
});
});
}
// Create entity with blueprint
async function createBlueprintEntity(scene: IScene, blueprintPath: string): Promise<Entity> {
const entity = scene.createEntity('BlueprintEntity');
const bpComponent = entity.addComponent(BlueprintComponent);
bpComponent.blueprintPath = blueprintPath;
bpComponent.blueprintAsset = await loadBlueprint(blueprintPath);
return entity;
}
```
### 4. Register System to Scene
```typescript
// During scene initialization
scene.addSystem(new BlueprintExecutionSystem());
```
## Creating Custom Nodes
### Using Decorators for Components
Use decorators to automatically generate blueprint nodes from components:
```typescript
import { Component, ECSComponent } from '@esengine/ecs-framework';
import { BlueprintExpose, BlueprintProperty, BlueprintMethod } from '@esengine/blueprint';
@ECSComponent('Health')
@BlueprintExpose({ displayName: 'Health Component' })
export class HealthComponent extends Component {
@BlueprintProperty({ displayName: 'Current Health', category: 'number' })
current: number = 100;
@BlueprintProperty({ displayName: 'Max Health', category: 'number' })
max: number = 100;
@BlueprintMethod({ displayName: 'Heal', isExec: true })
heal(amount: number): void {
this.current = Math.min(this.current + amount, this.max);
}
@BlueprintMethod({ displayName: 'Take Damage', isExec: true })
takeDamage(amount: number): void {
this.current = Math.max(this.current - amount, 0);
}
@BlueprintMethod({ displayName: 'Is Dead' })
isDead(): boolean {
return this.current <= 0;
}
}
```
### Register Component Nodes
```typescript
import { registerAllComponentNodes } from '@esengine/blueprint';
// Register all decorated components at application startup
registerAllComponentNodes();
```
### Manual Node Definition (Advanced)
For fully custom node logic:
```typescript
import {
BlueprintNodeTemplate,
INodeExecutor,
RegisterNode,
ExecutionContext,
ExecutionResult
} from '@esengine/blueprint';
const MyNodeTemplate: BlueprintNodeTemplate = {
type: 'MyCustomNode',
title: 'My Custom Node',
category: 'custom',
description: 'Custom node 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' }
]
};
@RegisterNode(MyNodeTemplate)
class MyNodeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = context.getInput<number>(node.id, 'value');
return {
outputs: { result: value * 2 },
nextExec: 'exec'
};
}
}
```
## Node Categories
| Category | Description | Color |
|----------|-------------|-------|
| `event` | Event nodes | 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 |
| `custom` | Custom nodes | Blue-gray |
## Best Practices
1. **File Organization**
- Place blueprint files in `assets/blueprints/` directory
- Use meaningful file names like `player-controller.blueprint.json`
2. **Component Design**
- Use `@BlueprintExpose` to mark components that should be exposed to blueprints
- Provide clear `displayName` for properties and methods
- Mark execution methods with `isExec: true`
3. **Performance Considerations**
- Avoid heavy computation in Tick events
- Use variables to cache intermediate results
- Pure function nodes automatically cache outputs
4. **Debugging Tips**
- Use Print nodes to output intermediate values
- Enable `vm.debug = true` to view execution logs
## FAQ
### Q: Node menu is empty?
A: Click the **Rescan** button to scan for blueprint node classes in your project. Make sure you have called `registerAllComponentNodes()`.
### Q: Blueprint doesn't execute?
A: Check:
1. Entity has `BlueprintComponent` added
2. `BlueprintExecutionSystem` is registered to scene
3. `blueprintAsset` is correctly loaded
4. `autoStart` is `true`
### Q: How to trigger custom events?
A: Trigger through VM:
```typescript
const bp = entity.getComponent(BlueprintComponent);
bp.vm?.triggerCustomEvent('OnPickup', { item: itemEntity });
```
## Related Documentation
- [Blueprint Runtime API](/en/modules/blueprint/) - BlueprintVM and core API
- [Custom Nodes](/en/modules/blueprint/custom-nodes) - Detailed node creation guide
- [Built-in Nodes](/en/modules/blueprint/nodes) - Built-in node reference

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,610 @@
---
title: "Blueprint Editor User Guide"
description: "Complete guide for using the Cocos Creator Blueprint Visual Scripting Editor"
---
<script src="/js/blueprint-graph.js"></script>
This guide covers how to use the Blueprint Visual Scripting Editor in Cocos Creator.
## Download & Installation
### Download
Download the latest version from GitHub Release (Free):
**[Download Cocos Node Editor v1.1.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.1.0)**
> QQ Group: **481923584** | Website: [esengine.cn](https://esengine.cn/)
### 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:
<div class="bp-graph" style="" data-connections='[{"from":"ex1-exec","to":"ex1-setprop","type":"exec"},{"from":"ex1-delta","to":"ex1-mul-a","type":"float"},{"from":"ex1-mul-result","to":"ex1-x","type":"float"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event Tick</span>
<span class="bp-header-exec" data-pin="ex1-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="ex1-delta"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">Delta Time</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 200px; top: 110px; width: 120px;">
<div class="bp-node-header math">Multiply</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="ex1-mul-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">A</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">B (Speed)</span>
<span class="bp-pin-value">100</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="ex1-mul-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 380px; top: 20px; width: 150px;">
<div class="bp-node-header function">Set Property</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="ex1-setprop"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7030c0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Target</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="ex1-x"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">x</span>
</div>
</div>
</div>
</div>
### Example 2: Health System
Check death after taking damage. `Event OnDamage` is a custom event node that can be triggered from code via `vm.triggerCustomEvent('OnDamage', { damage: 50 })`:
<div class="bp-graph" data-graph='{
"nodes": [
{
"id": "event", "title": "Event OnDamage", "category": "event",
"outputs": [
{"id": "event-exec", "type": "exec", "inHeader": true},
{"id": "event-self", "type": "entity", "label": "Self"},
{"id": "event-damage", "type": "float", "label": "Damage"}
]
},
{
"id": "getcomp", "title": "Get Component", "category": "function",
"inputs": [
{"id": "getcomp-exec", "type": "exec", "label": "Exec"},
{"id": "getcomp-entity", "type": "entity", "label": "Entity"},
{"id": "getcomp-type", "type": "string", "label": "Type", "value": "Health", "connected": false}
],
"outputs": [
{"id": "getcomp-out", "type": "exec"},
{"id": "getcomp-comp", "type": "component", "label": "Component"}
]
},
{
"id": "getprop", "title": "Get Property", "category": "pure",
"inputs": [
{"id": "getprop-target", "type": "component", "label": "Target"},
{"id": "getprop-prop", "type": "string", "label": "Property", "value": "current", "connected": false}
],
"outputs": [
{"id": "getprop-val", "type": "float", "label": "Value"}
]
},
{
"id": "sub", "title": "Subtract", "category": "math",
"inputs": [
{"id": "sub-exec", "type": "exec", "label": "Exec"},
{"id": "sub-a", "type": "float", "label": "A"},
{"id": "sub-b", "type": "float", "label": "B"}
],
"outputs": [
{"id": "sub-out", "type": "exec"},
{"id": "sub-result", "type": "float", "label": "Result"}
]
},
{
"id": "setprop", "title": "Set Property", "category": "function",
"inputs": [
{"id": "setprop-exec", "type": "exec", "label": "Exec"},
{"id": "setprop-target", "type": "component", "label": "Target"},
{"id": "setprop-prop", "type": "string", "label": "Property", "value": "current", "connected": false},
{"id": "setprop-val", "type": "float", "label": "Value"}
],
"outputs": [
{"id": "setprop-out", "type": "exec"}
]
},
{
"id": "lte", "title": "Less Or Equal", "category": "pure",
"inputs": [
{"id": "lte-a", "type": "float", "label": "A"},
{"id": "lte-b", "type": "float", "label": "B", "value": "0", "connected": false}
],
"outputs": [
{"id": "lte-result", "type": "bool", "label": "Result"}
]
},
{
"id": "branch", "title": "Branch", "category": "flow",
"inputs": [
{"id": "branch-exec", "type": "exec", "label": "Exec"},
{"id": "branch-cond", "type": "bool", "label": "Condition"}
],
"outputs": [
{"id": "branch-true", "type": "exec", "label": "True"},
{"id": "branch-false", "type": "exec", "label": "False"}
]
},
{
"id": "destroy", "title": "Destroy Entity", "category": "function",
"inputs": [
{"id": "destroy-exec", "type": "exec", "label": "Exec"},
{"id": "destroy-entity", "type": "entity", "label": "Entity"}
]
}
],
"connections": [
{"from": "event-exec", "to": "getcomp-exec", "type": "exec"},
{"from": "getcomp-out", "to": "sub-exec", "type": "exec"},
{"from": "sub-out", "to": "setprop-exec", "type": "exec"},
{"from": "setprop-out", "to": "branch-exec", "type": "exec"},
{"from": "branch-true", "to": "destroy-exec", "type": "exec"},
{"from": "event-self", "to": "getcomp-entity", "type": "entity"},
{"from": "event-self", "to": "destroy-entity", "type": "entity"},
{"from": "getcomp-comp", "to": "getprop-target", "type": "component"},
{"from": "getcomp-comp", "to": "setprop-target", "type": "component"},
{"from": "getprop-val", "to": "sub-a", "type": "float"},
{"from": "event-damage", "to": "sub-b", "type": "float"},
{"from": "sub-result", "to": "setprop-val", "type": "float"},
{"from": "sub-result", "to": "lte-a", "type": "float"},
{"from": "lte-result", "to": "branch-cond", "type": "bool"}
]
}'></div>
### Example 3: Delayed Spawning
Spawn an enemy every 2 seconds:
<div class="bp-graph" style="" data-connections='[{"from":"ex3-begin-exec","to":"ex3-loop","type":"exec"},{"from":"ex3-loop-body","to":"ex3-delay","type":"exec"},{"from":"ex3-delay-done","to":"ex3-create","type":"exec"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event BeginPlay</span>
<span class="bp-header-exec" data-pin="ex3-begin-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
</div>
<div class="bp-node" style="left: 240px; top: 20px; width: 130px;">
<div class="bp-node-header flow">Do N Times</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="ex3-loop"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
<span class="bp-pin-label">N</span>
<span class="bp-pin-value">10</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="ex3-loop-body"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Loop Body</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
<span class="bp-pin-label">Index</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Completed</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 430px; top: 20px; width: 120px;">
<div class="bp-node-header time">Delay</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="ex3-delay"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Duration</span>
<span class="bp-pin-value">2.0</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="ex3-delay-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Done</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 610px; top: 20px; width: 140px;">
<div class="bp-node-header function">Create Entity</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="ex3-create"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Name</span>
<span class="bp-pin-value">"Enemy"</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Entity</span>
</div>
</div>
</div>
</div>
## 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](/en/modules/blueprint/nodes) - Complete node list
- [Custom Nodes](/en/modules/blueprint/custom-nodes) - Create custom nodes
- [Runtime Integration](/en/modules/blueprint/vm) - Blueprint VM API
- [Examples](/en/modules/blueprint/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,162 @@
---
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
Blueprint Editor Plugin for Cocos Creator (Free):
**[Download Cocos Node Editor v1.1.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.1.0)**
> QQ Group: **481923584** | Website: [esengine.cn](https://esengine.cn/)
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,609 @@
---
title: "Built-in Nodes"
description: "Blueprint built-in node reference"
title: "ECS Node Reference"
description: "Blueprint built-in ECS operation nodes - complete reference with visual examples"
---
This document provides a complete reference for all built-in blueprint nodes with visual examples.
<script src="/js/blueprint-graph.js"></script>
## Pin Type Legend
<div class="bp-legend">
<div class="bp-legend-item"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff" stroke="#fff" stroke-width="1"/></svg> Execution Flow</div>
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#00a0e0" stroke-width="2"/></svg> Entity</div>
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#7030c0" stroke-width="2"/></svg> Component</div>
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#7ecd32" stroke-width="2"/></svg> Number</div>
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#e060e0" stroke-width="2"/></svg> String</div>
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#8c0000" stroke-width="2"/></svg> Boolean</div>
</div>
## Event Nodes
| Node | Description |
|------|-------------|
| `EventBeginPlay` | Triggered when blueprint starts |
| `EventTick` | Triggered each frame |
| `EventEndPlay` | Triggered when blueprint stops |
| `EventCollision` | Triggered on collision |
| `EventInput` | Triggered on input event |
| `EventTimer` | Triggered by timer |
| `EventMessage` | Triggered by custom message |
Lifecycle events as blueprint entry points:
## Flow Control Nodes
| Node | Description | Outputs |
|------|-------------|---------|
| `EventBeginPlay` | Triggered when blueprint starts | Exec, Self (Entity) |
| `EventTick` | Triggered each frame | Exec, Delta Time |
| `EventEndPlay` | Triggered when blueprint stops | Exec |
| 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 |
| `Gate` | Toggleable execution gate |
### Example: Game Initialization
## Time Nodes
<div class="bp-graph" style="" data-connections='[{"from":"en-beginplay-exec","to":"en-print-exec","type":"exec"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event BeginPlay</span>
<span class="bp-header-exec" data-pin="en-beginplay-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Self</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 280px; top: 20px; width: 170px;">
<div class="bp-node-header debug">Print</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-print-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Message</span>
<span class="bp-pin-value">"Game Started!"</span>
</div>
</div>
</div>
</div>
| Node | Description |
|------|-------------|
| `Delay` | Delay execution |
| `GetDeltaTime` | Get frame delta time |
| `GetTime` | Get runtime |
| `SetTimer` | Set timer |
| `ClearTimer` | Clear timer |
### Example: Per-Frame Movement
## Math Nodes
| Node | Description |
|------|-------------|
| `Add` | Addition |
| `Subtract` | Subtraction |
| `Multiply` | Multiplication |
| `Divide` | Division |
| `Abs` | Absolute value |
| `Clamp` | Clamp to range |
| `Lerp` | Linear interpolation |
| `Min` / `Max` | Minimum/Maximum |
| `Sin` / `Cos` | Trigonometric functions |
| `Sqrt` | Square root |
| `Power` | Power |
## 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
| 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 |
<div class="bp-graph" style="" data-connections='[{"from":"en-tick-exec","to":"en-setprop-exec","type":"exec"},{"from":"en-tick-delta","to":"en-mul-a","type":"float"},{"from":"en-mul-result","to":"en-setprop-x","type":"float"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event Tick</span>
<span class="bp-header-exec" data-pin="en-tick-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="en-tick-delta"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">Delta Time</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 200px; top: 110px; width: 120px;">
<div class="bp-node-header math">Multiply</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-mul-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">A</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">B</span>
<span class="bp-pin-value">100</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="en-mul-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 380px; top: 20px; width: 150px;">
<div class="bp-node-header function">Set Property</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-setprop-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7030c0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Target</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-setprop-x"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">x</span>
</div>
</div>
</div>
</div>
## Entity Nodes
Manipulate ECS entities:
| Node | Description | Type |
|------|-------------|------|
| `Get Self` | Get the entity owning this blueprint | Pure |
| `Create Entity` | Create a new entity in the scene | Exec |
| `Destroy Entity` | Destroy specified entity | Exec |
| `Destroy Self` | Destroy the owning entity | Exec |
| `Is Valid` | Check if entity is valid | Pure |
| `Get Entity Name` | Get entity name | Pure |
| `Set Entity Name` | Set entity name | Exec |
| `Find Entity By Name` | Find entity by name | Pure |
| `Find Entities By Tag` | Find all entities with tag | Pure |
### Example: Create Bullet
<div class="bp-graph" style="" data-connections='[{"from":"en-bp-exec","to":"en-create-exec","type":"exec"},{"from":"en-create-exec-out","to":"en-add-exec","type":"exec"},{"from":"en-create-entity","to":"en-add-entity","type":"entity"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event BeginPlay</span>
<span class="bp-header-exec" data-pin="en-bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Self</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
<div class="bp-node-header function">Create Entity</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-create-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="en-create-exec-out"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="en-create-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Entity</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 520px; top: 20px; width: 150px;">
<div class="bp-node-header function">Add Transform</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-add-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-add-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Entity</span>
</div>
</div>
</div>
</div>
## Component Nodes
Read and write component properties:
| Node | Description | Type |
|------|-------------|------|
| `Get Component` | Get component of specified type from entity | Pure |
| `Has Component` | Check if entity has specified component | Pure |
| `Add Component` | Add component to entity | Exec |
| `Remove Component` | Remove component from entity | Exec |
| `Get Property` | Get component property value | Pure |
| `Set Property` | Set component property value | Exec |
### Example: Modify Position
<div class="bp-graph" style="" data-connections='[{"from":"en-self-entity","to":"en-getcomp-entity","type":"entity"},{"from":"en-getcomp-transform","to":"en-getprop-target","type":"component"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 100px;">
<div class="bp-node-header pure">Get Self</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="en-self-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Entity</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 200px; top: 20px; width: 150px;">
<div class="bp-node-header pure">Get Component</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-getcomp-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Entity</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="en-getcomp-transform"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
<span class="bp-pin-label">Transform</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 430px; top: 20px; width: 120px;">
<div class="bp-node-header pure">Get Property</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-getprop-target"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
<span class="bp-pin-label">Target</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">x</span>
</div>
</div>
</div>
</div>
## Flow Control Nodes
Control blueprint execution flow:
| Node | Description |
|------|-------------|
| `GetSelf` | Get current entity |
| `GetComponent` | Get component |
| `HasComponent` | Check component |
| `AddComponent` | Add component |
| `RemoveComponent` | Remove component |
| `SpawnEntity` | Create entity |
| `DestroyEntity` | Destroy entity |
| `Branch` | Conditional branching (if/else) |
| `Sequence` | Execute multiple branches in order |
| `For Loop` | Loop specified number of times |
| `For Each` | Iterate over array elements |
| `While Loop` | Loop while condition is true |
| `Do Once` | Execute only once |
| `Flip Flop` | Alternate between A and B |
| `Gate` | Gate switch control |
### Example: Conditional Branch
<div class="bp-graph" style="" data-connections='[{"from":"en-cond-exec","to":"en-branch-exec","type":"exec"},{"from":"en-cond-result","to":"en-branch-cond","type":"bool"},{"from":"en-branch-true","to":"en-print1-exec","type":"exec"},{"from":"en-branch-false","to":"en-print2-exec","type":"exec"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 60px; width: 120px;">
<div class="bp-node-header pure">Condition</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="en-cond-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="en-cond-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#8c0000"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 220px; top: 60px; width: 110px;">
<div class="bp-node-header flow">Branch</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-branch-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-branch-cond"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#8c0000"/></svg></span>
<span class="bp-pin-label">Cond</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="en-branch-true"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">True</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="en-branch-false"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">False</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 420px; top: 20px; width: 120px;">
<div class="bp-node-header debug">Print</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-print1-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Msg</span>
<span class="bp-pin-value">"Yes"</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 420px; top: 130px; width: 120px;">
<div class="bp-node-header debug">Print</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-print2-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Msg</span>
<span class="bp-pin-value">"No"</span>
</div>
</div>
</div>
</div>
### Example: For Loop
<div class="bp-graph" style="" data-connections='[{"from":"en-forloop-bp-exec","to":"en-forloop-exec","type":"exec"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event BeginPlay</span>
<span class="bp-header-exec" data-pin="en-forloop-bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
</div>
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
<div class="bp-node-header flow">For Loop</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-forloop-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
<span class="bp-pin-label">First</span>
<span class="bp-pin-value">0</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Last</span>
<span class="bp-pin-value">10</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Body</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
<span class="bp-pin-label">Index</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Done</span>
</div>
</div>
</div>
</div>
## Time Nodes
| Node | Description | Output |
|------|-------------|--------|
| `Delay` | Delay execution by specified seconds | Exec |
| `Get Delta Time` | Get frame delta time | Float |
| `Get Time` | Get total runtime | Float |
### Example: Delayed Execution
<div class="bp-graph" style="" data-connections='[{"from":"en-delay-bp-exec","to":"en-delay-exec","type":"exec"},{"from":"en-delay-done","to":"en-delay-print-exec","type":"exec"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event BeginPlay</span>
<span class="bp-header-exec" data-pin="en-delay-bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
</div>
<div class="bp-node" style="left: 280px; top: 20px; width: 120px;">
<div class="bp-node-header time">Delay</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-delay-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Duration</span>
<span class="bp-pin-value">2.0</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="en-delay-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Done</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 490px; top: 20px; width: 130px;">
<div class="bp-node-header debug">Print</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-delay-print-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Msg</span>
<span class="bp-pin-value">"After 2s"</span>
</div>
</div>
</div>
</div>
## Math Nodes
### Basic Operations
| Node | Description | Inputs | Output |
|------|-------------|--------|--------|
| `Add` | Addition | A, B | A + B |
| `Subtract` | Subtraction | A, B | A - B |
| `Multiply` | Multiplication | A, B | A × B |
| `Divide` | Division | A, B | A / B |
| `Modulo` | Modulo | A, B | A % B |
### Math Functions
| Node | Description | Inputs | Output |
|------|-------------|--------|--------|
| `Abs` | Absolute value | Value | \|Value\| |
| `Sqrt` | Square root | Value | √Value |
| `Pow` | Power | Base, Exp | Base^Exp |
| `Floor` | Floor | Value | ⌊Value⌋ |
| `Ceil` | Ceiling | Value | ⌈Value⌉ |
| `Round` | Round | Value | round(Value) |
| `Clamp` | Clamp to range | Value, Min, Max | min(max(V, Min), Max) |
| `Lerp` | Linear interpolation | A, B, Alpha | A + (B-A) × Alpha |
| `Min` | Minimum | A, B | min(A, B) |
| `Max` | Maximum | A, B | max(A, B) |
### Trigonometric Functions
| Node | Description |
|------|-------------|
| `Sin` | Sine |
| `Cos` | Cosine |
| `Tan` | Tangent |
| `Asin` | Arc sine |
| `Acos` | Arc cosine |
| `Atan` | Arc tangent |
| `Atan2` | Two-argument arc tangent |
### Random Numbers
| Node | Description | Inputs | Output |
|------|-------------|--------|--------|
| `Random` | Random float [0, 1) | - | Float |
| `Random Range` | Random in range | Min, Max | Float |
| `Random Int` | Random integer | Min, Max | Int |
### Comparison Nodes
| Node | Description | Output |
|------|-------------|--------|
| `Equal` | A == B | Boolean |
| `Not Equal` | A != B | Boolean |
| `Greater` | A > B | Boolean |
| `Greater Or Equal` | A >= B | Boolean |
| `Less` | A < B | Boolean |
| `Less Or Equal` | A <= B | Boolean |
### Example: Clamp Value
<div class="bp-graph" style="" data-connections='[{"from":"en-rand-result","to":"en-clamp-value","type":"float"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 130px;">
<div class="bp-node-header math">Random Range</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Min</span>
<span class="bp-pin-value">0</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Max</span>
<span class="bp-pin-value">100</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="en-rand-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 240px; top: 20px; width: 130px;">
<div class="bp-node-header math">Clamp</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-clamp-value"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">Value</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Min</span>
<span class="bp-pin-value">20</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Max</span>
<span class="bp-pin-value">80</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
</div>
## Variable Nodes
| Node | Description |
|------|-------------|
| `GetVariable` | Get variable value |
| `SetVariable` | Set variable value |
Blueprint-defined variables automatically generate Get and Set nodes:
| Node | Description | Type |
|------|-------------|------|
| `Get <varname>` | Read variable value | Pure |
| `Set <varname>` | Set variable value | Exec |
### Example: Counter
<div class="bp-graph" style="" data-connections='[{"from":"en-cnt-tick-exec","to":"en-cnt-add-exec","type":"exec"},{"from":"en-cnt-get-value","to":"en-cnt-add-a","type":"int"},{"from":"en-cnt-add-result","to":"en-cnt-set-value","type":"int"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event Tick</span>
<span class="bp-header-exec" data-pin="en-cnt-tick-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">Delta</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 20px; top: 120px; width: 110px;">
<div class="bp-node-header variable">Get Count</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="en-cnt-get-value"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
<span class="bp-pin-label">Value</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 240px; top: 20px; width: 110px;">
<div class="bp-node-header math">Add</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-cnt-add-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-cnt-add-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
<span class="bp-pin-label">A</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
<span class="bp-pin-label">B</span>
<span class="bp-pin-value">1</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="en-cnt-add-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 430px; top: 20px; width: 110px;">
<div class="bp-node-header variable">Set Count</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="en-cnt-set-value"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
<span class="bp-pin-label">Value</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
</div>
</div>
</div>
## Debug Nodes
| Node | Description |
|------|-------------|
| `Print` | Print to console |
| `DrawDebugLine` | Draw debug line |
| `DrawDebugPoint` | Draw debug point |
| `Breakpoint` | Debug breakpoint |
| `Print` | Output message to console |
## Related Documentation
- [Blueprint Editor Guide](/en/modules/blueprint/editor-guide) - Learn how to use the editor
- [Custom Nodes](/en/modules/blueprint/custom-nodes) - Create custom nodes
- [Blueprint VM](/en/modules/blueprint/vm) - Runtime API

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,265 @@
---
title: "MongoDB Connection"
description: "MongoDB connection management, connection pooling, auto-reconnect"
---
## Configuration Options
```typescript
interface MongoConnectionConfig {
/** MongoDB connection URI */
uri: string
/** Database name */
database: string
/** Connection pool configuration */
pool?: {
minSize?: number // Minimum connections
maxSize?: number // Maximum connections
acquireTimeout?: number // Connection acquire timeout (ms)
maxLifetime?: number // Maximum connection lifetime (ms)
}
/** Auto-reconnect (default true) */
autoReconnect?: boolean
/** Reconnect interval (ms, default 5000) */
reconnectInterval?: number
/** Maximum reconnect attempts (default 10) */
maxReconnectAttempts?: number
}
```
## Complete Example
```typescript
import { createMongoConnection, MongoConnectionToken } from '@esengine/database-drivers'
const mongo = createMongoConnection({
uri: 'mongodb://localhost:27017',
database: 'game',
pool: {
minSize: 5,
maxSize: 20,
acquireTimeout: 5000,
maxLifetime: 300000
},
autoReconnect: true,
reconnectInterval: 5000,
maxReconnectAttempts: 10
})
// Event listeners
mongo.on('connected', () => {
console.log('MongoDB connected')
})
mongo.on('disconnected', () => {
console.log('MongoDB disconnected')
})
mongo.on('reconnecting', () => {
console.log('MongoDB reconnecting...')
})
mongo.on('reconnected', () => {
console.log('MongoDB reconnected')
})
mongo.on('error', (event) => {
console.error('MongoDB error:', event.error)
})
// Connect
await mongo.connect()
// Check status
console.log('Connected:', mongo.isConnected())
console.log('Ping:', await mongo.ping())
```
## IMongoConnection Interface
```typescript
interface IMongoConnection {
/** Connection ID */
readonly id: string
/** Connection state */
readonly state: ConnectionState
/** Establish connection */
connect(): Promise<void>
/** Disconnect */
disconnect(): Promise<void>
/** Check if connected */
isConnected(): boolean
/** Test connection */
ping(): Promise<boolean>
/** Get typed collection */
collection<T extends object>(name: string): IMongoCollection<T>
/** Get database interface */
getDatabase(): IMongoDatabase
/** Get native client (advanced usage) */
getNativeClient(): MongoClientType
/** Get native database (advanced usage) */
getNativeDatabase(): Db
}
```
## IMongoCollection Interface
Type-safe collection interface, decoupled from native MongoDB types:
```typescript
interface IMongoCollection<T extends object> {
readonly name: string
// Query
findOne(filter: object, options?: FindOptions): Promise<T | null>
find(filter: object, options?: FindOptions): Promise<T[]>
countDocuments(filter?: object): Promise<number>
// Insert
insertOne(doc: T): Promise<InsertOneResult>
insertMany(docs: T[]): Promise<InsertManyResult>
// Update
updateOne(filter: object, update: object): Promise<UpdateResult>
updateMany(filter: object, update: object): Promise<UpdateResult>
findOneAndUpdate(
filter: object,
update: object,
options?: FindOneAndUpdateOptions
): Promise<T | null>
// Delete
deleteOne(filter: object): Promise<DeleteResult>
deleteMany(filter: object): Promise<DeleteResult>
// Index
createIndex(
spec: Record<string, 1 | -1>,
options?: IndexOptions
): Promise<string>
}
```
## Usage Examples
### Basic CRUD
```typescript
interface User {
id: string
name: string
email: string
score: number
}
const users = mongo.collection<User>('users')
// Insert
await users.insertOne({
id: '1',
name: 'John',
email: 'john@example.com',
score: 100
})
// Query
const user = await users.findOne({ name: 'John' })
const topUsers = await users.find(
{ score: { $gte: 100 } },
{ sort: { score: -1 }, limit: 10 }
)
// Update
await users.updateOne(
{ id: '1' },
{ $inc: { score: 10 } }
)
// Delete
await users.deleteOne({ id: '1' })
```
### Batch Operations
```typescript
// Batch insert
await users.insertMany([
{ id: '1', name: 'Alice', email: 'alice@example.com', score: 100 },
{ id: '2', name: 'Bob', email: 'bob@example.com', score: 200 },
{ id: '3', name: 'Carol', email: 'carol@example.com', score: 150 }
])
// Batch update
await users.updateMany(
{ score: { $lt: 100 } },
{ $set: { status: 'inactive' } }
)
// Batch delete
await users.deleteMany({ status: 'inactive' })
```
### Index Management
```typescript
// Create indexes
await users.createIndex({ email: 1 }, { unique: true })
await users.createIndex({ score: -1 })
await users.createIndex({ name: 1, score: -1 })
```
## Integration with Other Modules
### With @esengine/database
```typescript
import { createMongoConnection } from '@esengine/database-drivers'
import { UserRepository, createRepository } from '@esengine/database'
const mongo = createMongoConnection({
uri: 'mongodb://localhost:27017',
database: 'game'
})
await mongo.connect()
// Use UserRepository
const userRepo = new UserRepository(mongo)
await userRepo.register({ username: 'john', password: '123456' })
// Use generic repository
const playerRepo = createRepository<Player>(mongo, 'players')
```
### With @esengine/transaction
```typescript
import { createMongoConnection } from '@esengine/database-drivers'
import { createMongoStorage, TransactionManager } from '@esengine/transaction'
const mongo = createMongoConnection({
uri: 'mongodb://localhost:27017',
database: 'game'
})
await mongo.connect()
// Create transaction storage (shared connection)
const storage = createMongoStorage(mongo)
await storage.ensureIndexes()
const txManager = new TransactionManager({ storage })
```

View File

@@ -0,0 +1,228 @@
---
title: "Redis Connection"
description: "Redis connection management, auto-reconnect, key prefix"
---
## Configuration Options
```typescript
interface RedisConnectionConfig {
/** Redis host */
host?: string
/** Redis port */
port?: number
/** Authentication password */
password?: string
/** Database number */
db?: number
/** Key prefix */
keyPrefix?: string
/** Auto-reconnect (default true) */
autoReconnect?: boolean
/** Reconnect interval (ms, default 5000) */
reconnectInterval?: number
/** Maximum reconnect attempts (default 10) */
maxReconnectAttempts?: number
}
```
## Complete Example
```typescript
import { createRedisConnection, RedisConnectionToken } from '@esengine/database-drivers'
const redis = createRedisConnection({
host: 'localhost',
port: 6379,
password: 'your-password',
db: 0,
keyPrefix: 'game:',
autoReconnect: true,
reconnectInterval: 5000,
maxReconnectAttempts: 10
})
// Event listeners
redis.on('connected', () => {
console.log('Redis connected')
})
redis.on('disconnected', () => {
console.log('Redis disconnected')
})
redis.on('error', (event) => {
console.error('Redis error:', event.error)
})
// Connect
await redis.connect()
// Check status
console.log('Connected:', redis.isConnected())
console.log('Ping:', await redis.ping())
```
## IRedisConnection Interface
```typescript
interface IRedisConnection {
/** Connection ID */
readonly id: string
/** Connection state */
readonly state: ConnectionState
/** Establish connection */
connect(): Promise<void>
/** Disconnect */
disconnect(): Promise<void>
/** Check if connected */
isConnected(): boolean
/** Test connection */
ping(): Promise<boolean>
/** Get value */
get(key: string): Promise<string | null>
/** Set value (optional TTL in seconds) */
set(key: string, value: string, ttl?: number): Promise<void>
/** Delete key */
del(key: string): Promise<boolean>
/** Check if key exists */
exists(key: string): Promise<boolean>
/** Set expiration (seconds) */
expire(key: string, seconds: number): Promise<boolean>
/** Get remaining TTL (seconds) */
ttl(key: string): Promise<number>
/** Get native client (advanced usage) */
getNativeClient(): Redis
}
```
## Usage Examples
### Basic Operations
```typescript
// Set value
await redis.set('user:1:name', 'John')
// Set value with expiration (1 hour)
await redis.set('session:abc123', 'user-data', 3600)
// Get value
const name = await redis.get('user:1:name')
// Check if key exists
const exists = await redis.exists('user:1:name')
// Delete key
await redis.del('user:1:name')
// Get remaining TTL
const ttl = await redis.ttl('session:abc123')
```
### Key Prefix
When `keyPrefix` is configured, all operations automatically add the prefix:
```typescript
const redis = createRedisConnection({
host: 'localhost',
keyPrefix: 'game:'
})
// Actual key is 'game:user:1'
await redis.set('user:1', 'data')
// Actual key queried is 'game:user:1'
const data = await redis.get('user:1')
```
### Advanced Operations
Use native client for advanced operations:
```typescript
const client = redis.getNativeClient()
// Using Pipeline
const pipeline = client.pipeline()
pipeline.set('key1', 'value1')
pipeline.set('key2', 'value2')
pipeline.set('key3', 'value3')
await pipeline.exec()
// Using Transactions
const multi = client.multi()
multi.incr('counter')
multi.get('counter')
const results = await multi.exec()
// Using Lua Scripts
const result = await client.eval(
`return redis.call('get', KEYS[1])`,
1,
'mykey'
)
```
## Integration with Transaction System
```typescript
import { createRedisConnection } from '@esengine/database-drivers'
import { RedisStorage, TransactionManager } from '@esengine/transaction'
const redis = createRedisConnection({
host: 'localhost',
port: 6379,
keyPrefix: 'tx:'
})
await redis.connect()
// Create transaction storage
const storage = new RedisStorage({
factory: () => redis.getNativeClient(),
prefix: 'tx:'
})
const txManager = new TransactionManager({ storage })
```
## Connection State
```typescript
type ConnectionState =
| 'disconnected' // Not connected
| 'connecting' // Connecting
| 'connected' // Connected
| 'disconnecting' // Disconnecting
| 'error' // Error state
```
## Events
| Event | Description |
|-------|-------------|
| `connected` | Connection established |
| `disconnected` | Connection closed |
| `reconnecting` | Reconnecting |
| `reconnected` | Reconnection successful |
| `error` | Error occurred |

View File

@@ -0,0 +1,185 @@
---
title: "Query Syntax"
description: "Query condition operators and syntax"
---
## Basic Queries
### Exact Match
```typescript
await repo.findMany({
where: {
name: 'John',
status: 'active'
}
})
```
### Using Operators
```typescript
await repo.findMany({
where: {
score: { $gte: 100 },
rank: { $in: ['gold', 'platinum'] }
}
})
```
## Query Operators
| Operator | Description | Example |
|----------|-------------|---------|
| `$eq` | Equal | `{ score: { $eq: 100 } }` |
| `$ne` | Not equal | `{ status: { $ne: 'banned' } }` |
| `$gt` | Greater than | `{ score: { $gt: 50 } }` |
| `$gte` | Greater than or equal | `{ level: { $gte: 10 } }` |
| `$lt` | Less than | `{ age: { $lt: 18 } }` |
| `$lte` | Less than or equal | `{ price: { $lte: 100 } }` |
| `$in` | In array | `{ rank: { $in: ['gold', 'platinum'] } }` |
| `$nin` | Not in array | `{ status: { $nin: ['banned', 'suspended'] } }` |
| `$like` | Pattern match | `{ name: { $like: '%john%' } }` |
| `$regex` | Regex match | `{ email: { $regex: '@gmail.com$' } }` |
## Logical Operators
### $or
```typescript
await repo.findMany({
where: {
$or: [
{ score: { $gte: 1000 } },
{ rank: 'legendary' }
]
}
})
```
### $and
```typescript
await repo.findMany({
where: {
$and: [
{ score: { $gte: 100 } },
{ score: { $lte: 500 } }
]
}
})
```
### Combined Usage
```typescript
await repo.findMany({
where: {
status: 'active',
$or: [
{ rank: 'gold' },
{ score: { $gte: 1000 } }
]
}
})
```
## Pattern Matching
### $like Syntax
- `%` - Matches any sequence of characters
- `_` - Matches single character
```typescript
// Starts with 'John'
{ name: { $like: 'John%' } }
// Ends with 'son'
{ name: { $like: '%son' } }
// Contains 'oh'
{ name: { $like: '%oh%' } }
// Second character is 'o'
{ name: { $like: '_o%' } }
```
### $regex Syntax
Uses standard regular expressions:
```typescript
// Starts with 'John' (case insensitive)
{ name: { $regex: '^john' } }
// Gmail email
{ email: { $regex: '@gmail\\.com$' } }
// Contains numbers
{ username: { $regex: '\\d+' } }
```
## Sorting
```typescript
await repo.findMany({
sort: {
score: 'desc', // Descending
name: 'asc' // Ascending
}
})
```
## Pagination
### Using limit/offset
```typescript
// First page
await repo.findMany({
limit: 20,
offset: 0
})
// Second page
await repo.findMany({
limit: 20,
offset: 20
})
```
### Using findPaginated
```typescript
const result = await repo.findPaginated(
{ page: 2, pageSize: 20 },
{ sort: { createdAt: 'desc' } }
)
```
## Complete Examples
```typescript
// Find active gold players with scores between 100-1000
// Sort by score descending, get top 10
const players = await repo.findMany({
where: {
status: 'active',
rank: 'gold',
score: { $gte: 100, $lte: 1000 }
},
sort: { score: 'desc' },
limit: 10
})
// Search for users with 'john' in username or gmail email
const users = await repo.findMany({
where: {
$or: [
{ username: { $like: '%john%' } },
{ email: { $regex: '@gmail\\.com$' } }
]
}
})
```

View File

@@ -0,0 +1,244 @@
---
title: "Repository API"
description: "Generic repository interface, CRUD operations, pagination, soft delete"
---
## Creating a Repository
### Using Factory Function
```typescript
import { createRepository } from '@esengine/database'
const playerRepo = createRepository<Player>(mongo, 'players')
// Enable soft delete
const playerRepo = createRepository<Player>(mongo, 'players', true)
```
### Extending Repository
```typescript
import { Repository, BaseEntity } from '@esengine/database'
interface Player extends BaseEntity {
name: string
score: number
}
class PlayerRepository extends Repository<Player> {
constructor(connection: IMongoConnection) {
super(connection, 'players', false) // Third param: enable soft delete
}
// Add custom methods
async findTopPlayers(limit: number): Promise<Player[]> {
return this.findMany({
sort: { score: 'desc' },
limit
})
}
}
```
## BaseEntity Interface
All entities must extend `BaseEntity`:
```typescript
interface BaseEntity {
id: string
createdAt: Date
updatedAt: Date
deletedAt?: Date // Used for soft delete
}
```
## Query Methods
### findById
```typescript
const player = await repo.findById('player-123')
```
### findOne
```typescript
const player = await repo.findOne({
where: { name: 'John' }
})
const topPlayer = await repo.findOne({
sort: { score: 'desc' }
})
```
### findMany
```typescript
// Simple query
const players = await repo.findMany({
where: { rank: 'gold' }
})
// Complex query
const players = await repo.findMany({
where: {
score: { $gte: 100 },
rank: { $in: ['gold', 'platinum'] }
},
sort: { score: 'desc', name: 'asc' },
limit: 10,
offset: 0
})
```
### findPaginated
```typescript
const result = await repo.findPaginated(
{ page: 1, pageSize: 20 },
{
where: { rank: 'gold' },
sort: { score: 'desc' }
}
)
console.log(result.data) // Player[]
console.log(result.total) // Total count
console.log(result.totalPages) // Total pages
console.log(result.hasNext) // Has next page
console.log(result.hasPrev) // Has previous page
```
### count
```typescript
const count = await repo.count({
where: { rank: 'gold' }
})
```
### exists
```typescript
const exists = await repo.exists({
where: { email: 'john@example.com' }
})
```
## Create Methods
### create
```typescript
const player = await repo.create({
name: 'John',
score: 0
})
// Automatically generates id, createdAt, updatedAt
```
### createMany
```typescript
const players = await repo.createMany([
{ name: 'Alice', score: 100 },
{ name: 'Bob', score: 200 },
{ name: 'Carol', score: 150 }
])
```
## Update Methods
### update
```typescript
const updated = await repo.update('player-123', {
score: 200,
rank: 'gold'
})
// Automatically updates updatedAt
```
## Delete Methods
### delete
```typescript
// Hard delete
await repo.delete('player-123')
// Soft delete (if enabled)
// Actually sets the deletedAt field
```
### deleteMany
```typescript
const count = await repo.deleteMany({
where: { score: { $lt: 10 } }
})
```
## Soft Delete
### Enabling Soft Delete
```typescript
const repo = createRepository<Player>(mongo, 'players', true)
```
### Query Behavior
```typescript
// Excludes soft-deleted records by default
const players = await repo.findMany()
// Include soft-deleted records
const allPlayers = await repo.findMany({
includeSoftDeleted: true
})
```
### Restore Records
```typescript
await repo.restore('player-123')
```
## QueryOptions
```typescript
interface QueryOptions<T> {
/** Query conditions */
where?: WhereCondition<T>
/** Sorting */
sort?: Partial<Record<keyof T, 'asc' | 'desc'>>
/** Limit count */
limit?: number
/** Offset */
offset?: number
/** Include soft-deleted records (only when soft delete is enabled) */
includeSoftDeleted?: boolean
}
```
## PaginatedResult
```typescript
interface PaginatedResult<T> {
data: T[]
total: number
page: number
pageSize: number
totalPages: number
hasNext: boolean
hasPrev: boolean
}
```

View File

@@ -0,0 +1,277 @@
---
title: "User Management"
description: "UserRepository for user registration, authentication, and role management"
---
## Overview
`UserRepository` provides out-of-the-box user management features:
- User registration and authentication
- Password hashing (using scrypt)
- Role management
- Account status management
## Quick Start
```typescript
import { createMongoConnection } from '@esengine/database-drivers'
import { UserRepository } from '@esengine/database'
const mongo = createMongoConnection({
uri: 'mongodb://localhost:27017',
database: 'game'
})
await mongo.connect()
const userRepo = new UserRepository(mongo)
```
## User Registration
```typescript
const user = await userRepo.register({
username: 'john',
password: 'securePassword123',
email: 'john@example.com', // Optional
displayName: 'John Doe', // Optional
roles: ['player'] // Optional, defaults to []
})
console.log(user)
// {
// id: 'uuid-...',
// username: 'john',
// email: 'john@example.com',
// displayName: 'John Doe',
// roles: ['player'],
// status: 'active',
// createdAt: Date,
// updatedAt: Date
// }
```
**Note**: `register` returns a `SafeUser` which excludes the password hash.
## User Authentication
```typescript
const user = await userRepo.authenticate('john', 'securePassword123')
if (user) {
console.log('Login successful:', user.username)
} else {
console.log('Invalid username or password')
}
```
## Password Management
### Change Password
```typescript
const success = await userRepo.changePassword(
userId,
'oldPassword123',
'newPassword456'
)
if (success) {
console.log('Password changed successfully')
} else {
console.log('Invalid current password')
}
```
### Reset Password
```typescript
// Admin directly resets password
const success = await userRepo.resetPassword(userId, 'newPassword123')
```
## Role Management
### Add Role
```typescript
await userRepo.addRole(userId, 'admin')
await userRepo.addRole(userId, 'moderator')
```
### Remove Role
```typescript
await userRepo.removeRole(userId, 'moderator')
```
### Query Roles
```typescript
// Find all admins
const admins = await userRepo.findByRole('admin')
// Check if user has a role
const user = await userRepo.findById(userId)
const isAdmin = user?.roles.includes('admin')
```
## Querying Users
### Find by Username
```typescript
const user = await userRepo.findByUsername('john')
```
### Find by Email
```typescript
const user = await userRepo.findByEmail('john@example.com')
```
### Find by Role
```typescript
const admins = await userRepo.findByRole('admin')
```
### Using Inherited Methods
```typescript
// Paginated query
const result = await userRepo.findPaginated(
{ page: 1, pageSize: 20 },
{
where: { status: 'active' },
sort: { createdAt: 'desc' }
}
)
// Complex query
const users = await userRepo.findMany({
where: {
status: 'active',
roles: { $in: ['admin', 'moderator'] }
}
})
```
## Account Status
```typescript
type UserStatus = 'active' | 'inactive' | 'banned' | 'suspended'
```
### Update Status
```typescript
await userRepo.update(userId, { status: 'banned' })
```
### Query by Status
```typescript
const activeUsers = await userRepo.findMany({
where: { status: 'active' }
})
const bannedUsers = await userRepo.findMany({
where: { status: 'banned' }
})
```
## Type Definitions
### UserEntity
```typescript
interface UserEntity extends BaseEntity {
username: string
passwordHash: string
email?: string
displayName?: string
roles: string[]
status: UserStatus
lastLoginAt?: Date
}
```
### SafeUser
```typescript
type SafeUser = Omit<UserEntity, 'passwordHash'>
```
### CreateUserParams
```typescript
interface CreateUserParams {
username: string
password: string
email?: string
displayName?: string
roles?: string[]
}
```
## Password Utilities
Standalone password utility functions:
```typescript
import { hashPassword, verifyPassword } from '@esengine/database'
// Hash password
const hash = await hashPassword('myPassword123')
// Verify password
const isValid = await verifyPassword('myPassword123', hash)
```
### Security Notes
- Uses Node.js built-in `scrypt` algorithm
- Automatically generates random salt
- Uses secure iteration parameters by default
- Hash format: `salt:hash` (both hex encoded)
## Extending UserRepository
```typescript
import { UserRepository, UserEntity } from '@esengine/database'
interface GameUser extends UserEntity {
level: number
experience: number
coins: number
}
class GameUserRepository extends UserRepository {
// Override collection name
constructor(connection: IMongoConnection) {
super(connection, 'game_users')
}
// Add game-related methods
async addExperience(userId: string, amount: number): Promise<GameUser | null> {
const user = await this.findById(userId) as GameUser | null
if (!user) return null
const newExp = user.experience + amount
const newLevel = Math.floor(newExp / 1000) + 1
return this.update(userId, {
experience: newExp,
level: newLevel
}) as Promise<GameUser | null>
}
async findTopPlayers(limit: number = 10): Promise<GameUser[]> {
return this.findMany({
sort: { level: 'desc', experience: 'desc' },
limit
}) as Promise<GameUser[]>
}
}
```

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

@@ -0,0 +1,326 @@
---
title: "Fixed-Point Numbers"
description: "Deterministic fixed-point math library for lockstep games"
---
`@esengine/ecs-framework-math` provides deterministic fixed-point calculations designed for **Lockstep** architecture. Fixed-point numbers guarantee identical results across all platforms.
## Why Fixed-Point?
Floating-point numbers may produce different rounding results on different platforms:
```typescript
// Floating-point: may differ across platforms
const a = 0.1 + 0.2; // 0.30000000000000004 (some platforms)
// 0.3 (other platforms)
// Fixed-point: consistent everywhere
const x = Fixed32.from(0.1);
const y = Fixed32.from(0.2);
const z = x.add(y); // raw = 19661 (all platforms)
```
| Feature | Floating-Point | Fixed-Point |
|---------|----------------|-------------|
| Cross-platform consistency | ❌ May differ | ✅ Identical |
| Network sync mode | State sync | Lockstep |
| Game types | FPS, RPG | RTS, MOBA, Fighting |
## Installation
```bash
npm install @esengine/ecs-framework-math
```
## Fixed32 Fixed-Point Number
Q16.16 format: 16-bit integer + 16-bit fraction, range ±32767.99998.
### Creating Fixed-Point Numbers
```typescript
import { Fixed32 } from '@esengine/ecs-framework-math';
// From floating-point
const speed = Fixed32.from(5.5);
// From integer (no precision loss)
const count = Fixed32.fromInt(10);
// From raw value (after network receive)
const received = Fixed32.fromRaw(360448); // equals 5.5
// Predefined constants
Fixed32.ZERO // 0
Fixed32.ONE // 1
Fixed32.HALF // 0.5
Fixed32.PI // π
Fixed32.TWO_PI // 2π
Fixed32.HALF_PI // π/2
```
### Basic Operations
```typescript
const a = Fixed32.from(10);
const b = Fixed32.from(3);
const sum = a.add(b); // 13
const diff = a.sub(b); // 7
const prod = a.mul(b); // 30
const quot = a.div(b); // 3.333...
const mod = a.mod(b); // 1
const neg = a.neg(); // -10
const abs = neg.abs(); // 10
```
### Comparison Operations
```typescript
const x = Fixed32.from(5);
const y = Fixed32.from(3);
x.eq(y) // false - equal
x.ne(y) // true - not equal
x.lt(y) // false - less than
x.le(y) // false - less or equal
x.gt(y) // true - greater than
x.ge(y) // true - greater or equal
x.isZero() // false
x.isPositive() // true
x.isNegative() // false
```
### Math Functions
```typescript
// Square root (Newton's method, deterministic)
const sqrt = Fixed32.sqrt(Fixed32.from(16)); // 4
// Rounding
Fixed32.floor(Fixed32.from(3.7)) // 3
Fixed32.ceil(Fixed32.from(3.2)) // 4
Fixed32.round(Fixed32.from(3.5)) // 4
// Clamping
Fixed32.clamp(value, min, max)
// Linear interpolation
Fixed32.lerp(from, to, t)
// Min/Max
Fixed32.min(a, b)
Fixed32.max(a, b)
```
### Type Conversion
```typescript
const value = Fixed32.from(3.14159);
// To float (for rendering)
const float = value.toNumber(); // 3.14159
// Get raw value (for network)
const raw = value.toRaw(); // 205887
// To integer (floor)
const int = value.toInt(); // 3
```
## FixedVector2 Fixed-Point Vector
Immutable 2D vector, all operations return new instances.
### Creating Vectors
```typescript
import { FixedVector2, Fixed32 } from '@esengine/ecs-framework-math';
// From floating-point
const pos = FixedVector2.from(100, 200);
// From raw values (after network receive)
const received = FixedVector2.fromRaw(6553600, 13107200);
// From Fixed32
const vec = new FixedVector2(Fixed32.from(10), Fixed32.from(20));
// Predefined constants
FixedVector2.ZERO // (0, 0)
FixedVector2.ONE // (1, 1)
FixedVector2.RIGHT // (1, 0)
FixedVector2.LEFT // (-1, 0)
FixedVector2.UP // (0, 1)
FixedVector2.DOWN // (0, -1)
```
### Vector Operations
```typescript
const a = FixedVector2.from(3, 4);
const b = FixedVector2.from(1, 2);
// Basic operations
const sum = a.add(b); // (4, 6)
const diff = a.sub(b); // (2, 2)
const scaled = a.mul(Fixed32.from(2)); // (6, 8)
const divided = a.div(Fixed32.from(2)); // (1.5, 2)
// Vector products
const dot = a.dot(b); // 3*1 + 4*2 = 11
const cross = a.cross(b); // 3*2 - 4*1 = 2
// Length
const lenSq = a.lengthSquared(); // 25
const len = a.length(); // 5
// Normalize
const norm = a.normalize(); // (0.6, 0.8)
// Distance
const dist = a.distanceTo(b); // sqrt((3-1)² + (4-2)²)
```
### Rotation and Angles
```typescript
import { FixedMath } from '@esengine/ecs-framework-math';
const vec = FixedVector2.from(1, 0);
const angle = Fixed32.from(Math.PI / 2); // 90 degrees
// Rotate vector
const rotated = vec.rotate(angle); // (0, 1)
// Rotate around point
const center = FixedVector2.from(5, 5);
const around = vec.rotateAround(center, angle);
// Get vector angle
const vecAngle = vec.angle();
// Angle between vectors
const between = vec.angleTo(other);
// Create unit vector from angle
const dir = FixedVector2.fromAngle(angle);
// From polar coordinates
const polar = FixedVector2.fromPolar(length, angle);
```
### Type Conversion
```typescript
const pos = FixedVector2.from(100.5, 200.5);
// To float object (for rendering)
const obj = pos.toObject(); // { x: 100.5, y: 200.5 }
// To array
const arr = pos.toArray(); // [100.5, 200.5]
// Get raw values (for network)
const raw = pos.toRawObject(); // { x: 6586368, y: 13140992 }
```
## FixedMath Trigonometric Functions
Deterministic trigonometric functions using lookup tables.
```typescript
import { FixedMath, Fixed32 } from '@esengine/ecs-framework-math';
const angle = Fixed32.from(Math.PI / 6); // 30 degrees
// Trigonometric functions
const sin = FixedMath.sin(angle); // 0.5
const cos = FixedMath.cos(angle); // 0.866
const tan = FixedMath.tan(angle); // 0.577
// Inverse trigonometric
const atan = FixedMath.atan2(y, x);
const asin = FixedMath.asin(value);
const acos = FixedMath.acos(value);
// Normalize angle to [-π, π]
const normalized = FixedMath.normalizeAngle(angle);
// Angle difference (shortest path)
const delta = FixedMath.angleDelta(from, to);
// Angle interpolation (handles 360° wrap)
const lerped = FixedMath.lerpAngle(from, to, t);
// Radian/degree conversion
const deg = FixedMath.radToDeg(rad);
const rad = FixedMath.degToRad(deg);
```
## Best Practices
### 1. Use Fixed-Point Throughout
```typescript
// ✅ Correct: all game logic uses fixed-point
function calculateDamage(baseDamage: Fixed32, multiplier: Fixed32): Fixed32 {
return baseDamage.mul(multiplier);
}
// ❌ Wrong: mixing floating-point
function calculateDamage(baseDamage: number, multiplier: number): number {
return baseDamage * multiplier; // may be inconsistent
}
```
### 2. Only Convert to Float for Rendering
```typescript
// Game logic
const position: FixedVector2 = calculatePosition(input);
// Rendering
const { x, y } = position.toObject();
sprite.position.set(x, y);
```
### 3. Use Raw Values for Network
```typescript
// ✅ Correct: transmit raw integers
const raw = position.toRawObject();
send(JSON.stringify(raw));
// ❌ Wrong: transmit floats
const float = position.toObject();
send(JSON.stringify(float)); // may lose precision
```
### 4. Use FixedMath for Trigonometry
```typescript
// ✅ Correct: use lookup tables
const direction = FixedVector2.fromAngle(FixedMath.atan2(dy, dx));
// ❌ Wrong: use Math library
const angle = Math.atan2(dy.toNumber(), dx.toNumber()); // non-deterministic
```
## API Exports
```typescript
import {
Fixed32,
FixedVector2,
FixedMath,
type IFixed32,
type IFixedVector2
} from '@esengine/ecs-framework-math';
```
## Related Docs
- [State Sync](/en/modules/network/sync) - Fixed-point snapshot buffer
- [Client Prediction](/en/modules/network/prediction) - Fixed-point client prediction

View File

@@ -0,0 +1,679 @@
---
title: "HTTP Routing"
description: "HTTP REST API routing with WebSocket port sharing support"
---
`@esengine/server` includes a lightweight HTTP routing feature that can share the same port with WebSocket services, making it easy to implement REST APIs.
## Quick Start
### Inline Route Definition
The simplest way is to define HTTP routes directly when creating the server:
```typescript
import { createServer } from '@esengine/server'
const server = await createServer({
port: 3000,
http: {
'/api/health': (req, res) => {
res.json({ status: 'ok', time: Date.now() })
},
'/api/users': {
GET: (req, res) => {
res.json({ users: [] })
},
POST: async (req, res) => {
const body = req.body as { name: string }
res.status(201).json({ id: '1', name: body.name })
}
}
},
cors: true // Enable CORS
})
await server.start()
```
### File-based Routing
For larger projects, file-based routing is recommended. Create a `src/http` directory where each file corresponds to a route:
```typescript
// src/http/login.ts
import { defineHttp } from '@esengine/server'
interface LoginBody {
username: string
password: string
}
export default defineHttp<LoginBody>({
method: 'POST',
handler(req, res) {
const { username, password } = req.body as LoginBody
// Validate user...
if (username === 'admin' && password === '123456') {
res.json({ token: 'jwt-token-here', userId: 'user-1' })
} else {
res.error(401, 'Invalid username or password')
}
}
})
```
```typescript
// server.ts
import { createServer } from '@esengine/server'
const server = await createServer({
port: 3000,
httpDir: './src/http', // HTTP routes directory
httpPrefix: '/api', // Route prefix
cors: true
})
await server.start()
// Route: POST /api/login
```
## defineHttp Definition
`defineHttp` is used to define type-safe HTTP handlers:
```typescript
import { defineHttp } from '@esengine/server'
interface CreateUserBody {
username: string
email: string
password: string
}
export default defineHttp<CreateUserBody>({
// HTTP method (default POST)
method: 'POST',
// Handler function
handler(req, res) {
const body = req.body as CreateUserBody
// Handle request...
res.status(201).json({ id: 'new-user-id' })
}
})
```
### Supported HTTP Methods
```typescript
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS'
```
## HttpRequest Object
The HTTP request object contains the following properties:
```typescript
interface HttpRequest {
/** Raw Node.js IncomingMessage */
raw: IncomingMessage
/** HTTP method */
method: string
/** Request path */
path: string
/** Route parameters (extracted from URL path, e.g., /users/:id) */
params: Record<string, string>
/** Query parameters */
query: Record<string, string>
/** Request headers */
headers: Record<string, string | string[] | undefined>
/** Parsed request body */
body: unknown
/** Client IP */
ip: string
}
```
### Usage Examples
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// Get query parameters
const page = parseInt(req.query.page ?? '1')
const limit = parseInt(req.query.limit ?? '10')
// Get request headers
const authHeader = req.headers.authorization
// Get client IP
console.log('Request from:', req.ip)
res.json({ page, limit })
}
})
```
### Body Parsing
The request body is automatically parsed based on `Content-Type`:
- `application/json` - Parsed as JSON object
- `application/x-www-form-urlencoded` - Parsed as key-value object
- Others - Kept as raw string
```typescript
export default defineHttp<{ name: string; age: number }>({
method: 'POST',
handler(req, res) {
// body is already parsed
const { name, age } = req.body as { name: string; age: number }
res.json({ received: { name, age } })
}
})
```
## HttpResponse Object
The HTTP response object provides a chainable API:
```typescript
interface HttpResponse {
/** Raw Node.js ServerResponse */
raw: ServerResponse
/** Set status code */
status(code: number): HttpResponse
/** Set response header */
header(name: string, value: string): HttpResponse
/** Send JSON response */
json(data: unknown): void
/** Send text response */
text(data: string): void
/** Send error response */
error(code: number, message: string): void
}
```
### Usage Examples
```typescript
export default defineHttp({
method: 'POST',
handler(req, res) {
// Set status code and custom headers
res
.status(201)
.header('X-Custom-Header', 'value')
.json({ created: true })
}
})
```
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// Send error response
res.error(404, 'Resource not found')
// Equivalent to: res.status(404).json({ error: 'Resource not found' })
}
})
```
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// Send plain text
res.text('Hello, World!')
}
})
```
## File Routing Conventions
### Name Conversion
File names are automatically converted to route paths:
| File Path | Route Path (prefix=/api) |
|-----------|-------------------------|
| `login.ts` | `/api/login` |
| `users/profile.ts` | `/api/users/profile` |
| `users/[id].ts` | `/api/users/:id` |
| `game/room/[roomId].ts` | `/api/game/room/:roomId` |
### Dynamic Route Parameters
Use `[param]` syntax to define dynamic parameters:
```typescript
// src/http/users/[id].ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
handler(req, res) {
// Get route parameter directly from params
const { id } = req.params
res.json({ userId: id })
}
})
```
Multiple parameters:
```typescript
// src/http/users/[userId]/posts/[postId].ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
handler(req, res) {
const { userId, postId } = req.params
res.json({ userId, postId })
}
})
```
### Skip Rules
The following files are automatically skipped:
- Files starting with `_` (e.g., `_helper.ts`)
- `index.ts` / `index.js` files
- Non `.ts` / `.js` / `.mts` / `.mjs` files
### Directory Structure Example
```
src/
└── http/
├── _utils.ts # Skipped (underscore prefix)
├── index.ts # Skipped (index file)
├── health.ts # GET /api/health
├── login.ts # POST /api/login
├── register.ts # POST /api/register
└── users/
├── index.ts # Skipped
├── list.ts # GET /api/users/list
└── [id].ts # GET /api/users/:id
```
## CORS Configuration
### Quick Enable
```typescript
const server = await createServer({
port: 3000,
cors: true // Use default configuration
})
```
### Custom Configuration
```typescript
const server = await createServer({
port: 3000,
cors: {
// Allowed origins
origin: ['http://localhost:5173', 'https://myapp.com'],
// Or use wildcard
// origin: '*',
// origin: true, // Reflect request origin
// Allowed HTTP methods
methods: ['GET', 'POST', 'PUT', 'DELETE'],
// Allowed request headers
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
// Allow credentials (cookies)
credentials: true,
// Preflight cache max age (seconds)
maxAge: 86400
}
})
```
### CorsOptions Type
```typescript
interface CorsOptions {
/** Allowed origins: string, string array, true (reflect) or '*' */
origin?: string | string[] | boolean
/** Allowed HTTP methods */
methods?: string[]
/** Allowed request headers */
allowedHeaders?: string[]
/** Allow credentials */
credentials?: boolean
/** Preflight cache max age (seconds) */
maxAge?: number
}
```
## Route Merging
File routes and inline routes can be used together, with inline routes having higher priority:
```typescript
const server = await createServer({
port: 3000,
httpDir: './src/http',
httpPrefix: '/api',
// Inline routes merge with file routes
http: {
'/health': (req, res) => res.json({ status: 'ok' }),
'/api/special': (req, res) => res.json({ special: true })
}
})
```
## Sharing Port with WebSocket
HTTP routes automatically share the same port with WebSocket services:
```typescript
const server = await createServer({
port: 3000,
// WebSocket related config
apiDir: './src/api',
msgDir: './src/msg',
// HTTP related config
httpDir: './src/http',
httpPrefix: '/api',
cors: true
})
await server.start()
// Same port 3000:
// - WebSocket: ws://localhost:3000
// - HTTP API: http://localhost:3000/api/*
```
## Complete Examples
### Game Server Login API
```typescript
// src/http/auth/login.ts
import { defineHttp } from '@esengine/server'
import { createJwtAuthProvider } from '@esengine/server/auth'
interface LoginRequest {
username: string
password: string
}
interface LoginResponse {
token: string
userId: string
expiresAt: number
}
const jwtProvider = createJwtAuthProvider({
secret: process.env.JWT_SECRET!,
expiresIn: 3600
})
export default defineHttp<LoginRequest>({
method: 'POST',
async handler(req, res) {
const { username, password } = req.body as LoginRequest
// Validate user
const user = await db.users.findByUsername(username)
if (!user || !await verifyPassword(password, user.passwordHash)) {
res.error(401, 'Invalid username or password')
return
}
// Generate JWT
const token = jwtProvider.sign({
sub: user.id,
name: user.username,
roles: user.roles
})
const response: LoginResponse = {
token,
userId: user.id,
expiresAt: Date.now() + 3600 * 1000
}
res.json(response)
}
})
```
### Game Data Query API
```typescript
// src/http/game/leaderboard.ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
async handler(req, res) {
const limit = parseInt(req.query.limit ?? '10')
const offset = parseInt(req.query.offset ?? '0')
const players = await db.players.findMany({
sort: { score: 'desc' },
limit,
offset
})
res.json({
data: players,
pagination: { limit, offset }
})
}
})
```
## Middleware
### Middleware Type
Middleware are functions that execute before and after route handlers:
```typescript
type HttpMiddleware = (
req: HttpRequest,
res: HttpResponse,
next: () => Promise<void>
) => void | Promise<void>
```
### Built-in Middleware
```typescript
import {
requestLogger,
bodyLimit,
responseTime,
requestId,
securityHeaders
} from '@esengine/server'
const server = await createServer({
port: 3000,
http: { /* ... */ },
// Global middleware configured via createHttpRouter
})
```
#### requestLogger - Request Logging
```typescript
import { requestLogger } from '@esengine/server'
// Log request and response time
requestLogger()
// Also log request body
requestLogger({ logBody: true })
```
#### bodyLimit - Request Body Size Limit
```typescript
import { bodyLimit } from '@esengine/server'
// Limit request body to 1MB
bodyLimit(1024 * 1024)
```
#### responseTime - Response Time Header
```typescript
import { responseTime } from '@esengine/server'
// Automatically add X-Response-Time header
responseTime()
```
#### requestId - Request ID
```typescript
import { requestId } from '@esengine/server'
// Auto-generate and add X-Request-ID header
requestId()
// Custom header name
requestId('X-Trace-ID')
```
#### securityHeaders - Security Headers
```typescript
import { securityHeaders } from '@esengine/server'
// Add common security response headers
securityHeaders()
// Custom configuration
securityHeaders({
hidePoweredBy: true,
frameOptions: 'DENY',
noSniff: true
})
```
### Custom Middleware
```typescript
import type { HttpMiddleware } from '@esengine/server'
// Authentication middleware
const authMiddleware: HttpMiddleware = async (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) {
res.error(401, 'Unauthorized')
return // Don't call next(), terminate request
}
// Validate token...
(req as any).userId = 'decoded-user-id'
await next() // Continue to next middleware and handler
}
```
### Using Middleware
#### With createHttpRouter
```typescript
import { createHttpRouter, requestLogger, bodyLimit } from '@esengine/server'
const router = createHttpRouter({
'/api/users': (req, res) => res.json([]),
'/api/admin': {
GET: {
handler: (req, res) => res.json({ admin: true }),
middlewares: [adminAuthMiddleware] // Route-level middleware
}
}
}, {
middlewares: [requestLogger(), bodyLimit(1024 * 1024)], // Global middleware
timeout: 30000 // Global timeout 30 seconds
})
```
## Request Timeout
### Global Timeout
```typescript
import { createHttpRouter } from '@esengine/server'
const router = createHttpRouter({
'/api/data': async (req, res) => {
// If processing exceeds 30 seconds, auto-return 408 Request Timeout
await someSlowOperation()
res.json({ data: 'result' })
}
}, {
timeout: 30000 // 30 seconds
})
```
### Route-level Timeout
```typescript
const router = createHttpRouter({
'/api/quick': (req, res) => res.json({ fast: true }),
'/api/slow': {
POST: {
handler: async (req, res) => {
await verySlowOperation()
res.json({ done: true })
},
timeout: 120000 // This route allows 2 minutes
}
}
}, {
timeout: 10000 // Global 10 seconds (overridden by route-level)
})
```
## Best Practices
1. **Use defineHttp** - Get better type hints and code organization
2. **Unified Error Handling** - Use `res.error()` to return consistent error format
3. **Enable CORS** - Required for frontend-backend separation
4. **Directory Organization** - Organize HTTP route files by functional modules
5. **Validate Input** - Always validate `req.body` and `req.query` content
6. **Status Code Standards** - Follow HTTP status code conventions (200, 201, 400, 401, 404, 500, etc.)
7. **Use Middleware** - Implement cross-cutting concerns like auth, logging, rate limiting via middleware
8. **Set Timeouts** - Prevent slow requests from blocking the server

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

@@ -252,3 +252,145 @@ if (predictionSystem) {
console.log('Current sequence:', predictionSystem.inputSequence);
}
```
---
## Fixed-Point Client Prediction (Lockstep)
Deterministic client prediction for **Lockstep** architecture.
> See [Fixed-Point Numbers](/en/modules/network/fixed-point) for math basics
### Basic Usage
```typescript
import {
FixedClientPrediction,
createFixedClientPrediction,
type IFixedPredictor,
type IFixedStatePositionExtractor
} from '@esengine/network';
import { Fixed32, FixedVector2 } from '@esengine/ecs-framework-math';
// Define game state
interface GameState {
position: FixedVector2;
velocity: FixedVector2;
}
// Implement predictor (must use fixed-point arithmetic)
const predictor: IFixedPredictor<GameState, PlayerInput> = {
predict(state: GameState, input: PlayerInput, deltaTime: Fixed32): GameState {
const speed = Fixed32.from(100);
const inputVec = FixedVector2.from(input.dx, input.dy);
const velocity = inputVec.normalize().mul(speed);
const displacement = velocity.mul(deltaTime);
return {
position: state.position.add(displacement),
velocity
};
}
};
// Create prediction
const prediction = createFixedClientPrediction(predictor, {
maxUnacknowledgedInputs: 60,
fixedDeltaTime: Fixed32.from(1 / 60),
reconciliationThreshold: Fixed32.from(0.001),
enableSmoothReconciliation: false // Usually disabled for lockstep
});
```
### Record Input
```typescript
function onUpdate(input: PlayerInput, currentState: GameState) {
// Record input and get predicted state
const predicted = prediction.recordInput(input, currentState);
// Render predicted state
const pos = predicted.position.toObject();
sprite.position.set(pos.x, pos.y);
// Send input
socket.send(JSON.stringify({
frame: prediction.currentFrame,
input
}));
}
```
### Server Reconciliation
```typescript
// Position extractor
const posExtractor: IFixedStatePositionExtractor<GameState> = {
getPosition(state: GameState): FixedVector2 {
return state.position;
}
};
// When receiving server state
function onServerState(serverState: GameState, serverFrame: number) {
const reconciled = prediction.reconcile(
serverState,
serverFrame,
posExtractor
);
}
```
### Rollback and Replay
```typescript
// Rollback when desync detected
const correctedState = prediction.rollbackAndResimulate(
serverFrame,
authoritativeState
);
// View historical state
const historicalState = prediction.getStateAtFrame(100);
```
### Preset Movement Predictor
```typescript
import {
createFixedMovementPredictor,
createFixedMovementPositionExtractor,
type IFixedMovementInput,
type IFixedMovementState
} from '@esengine/network';
// Create movement predictor (speed 100 units/sec)
const movePredictor = createFixedMovementPredictor(Fixed32.from(100));
const posExtractor = createFixedMovementPositionExtractor();
const prediction = createFixedClientPrediction<IFixedMovementState, IFixedMovementInput>(
movePredictor,
{ fixedDeltaTime: Fixed32.from(1 / 60) }
);
// Input format
const input: IFixedMovementInput = { dx: 1, dy: 0 };
```
### API Exports
```typescript
import {
FixedClientPrediction,
createFixedClientPrediction,
createFixedMovementPredictor,
createFixedMovementPositionExtractor,
type IFixedInputSnapshot,
type IFixedPredictedState,
type IFixedPredictor,
type IFixedStatePositionExtractor,
type FixedClientPredictionConfig,
type IFixedMovementInput,
type IFixedMovementState
} from '@esengine/network';
```

View File

@@ -90,128 +90,21 @@ await server.start()
Supports HTTP API sharing the same port with WebSocket, ideal for login, registration, and similar scenarios.
### File-based Routing
Create route files in the `httpDir` directory, automatically mapped to HTTP endpoints:
```
src/http/
├── login.ts → POST /api/login
├── register.ts → POST /api/register
├── health.ts → GET /api/health (set method: 'GET')
└── users/
└── [id].ts → POST /api/users/:id (dynamic route)
```
### Define Routes
Use `defineHttp` to define type-safe route handlers:
```typescript
// src/http/login.ts
import { defineHttp } from '@esengine/server'
const server = await createServer({
port: 3000,
httpDir: './src/http', // HTTP routes directory
httpPrefix: '/api', // Route prefix
cors: true,
interface LoginBody {
username: string
password: string
}
export default defineHttp<LoginBody>({
method: 'POST', // Default POST, options: GET/PUT/DELETE/PATCH
handler(req, res) {
const { username, password } = req.body
// Validate credentials...
if (!isValid(username, password)) {
res.error(401, 'Invalid credentials')
return
}
// Generate token...
res.json({ token: '...', userId: '...' })
// Or inline definition
http: {
'/health': (req, res) => res.json({ status: 'ok' })
}
})
```
### Request Object (HttpRequest)
```typescript
interface HttpRequest {
raw: IncomingMessage // Node.js raw request
method: string // Request method
path: string // Request path
query: Record<string, string> // Query parameters
headers: Record<string, string | string[] | undefined>
body: unknown // Parsed JSON body
ip: string // Client IP
}
```
### Response Object (HttpResponse)
```typescript
interface HttpResponse {
raw: ServerResponse // Node.js raw response
status(code: number): HttpResponse // Set status code (chainable)
header(name: string, value: string): HttpResponse // Set header (chainable)
json(data: unknown): void // Send JSON
text(data: string): void // Send text
error(code: number, message: string): void // Send error
}
```
### Usage Example
```typescript
// Complete login server example
import { createServer, defineHttp } from '@esengine/server'
import { createJwtAuthProvider, withAuth } from '@esengine/server/auth'
const jwtProvider = createJwtAuthProvider({
secret: process.env.JWT_SECRET!,
expiresIn: 3600 * 24,
})
const server = await createServer({
port: 8080,
httpDir: 'src/http',
httpPrefix: '/api',
cors: true,
})
// Wrap with auth (WebSocket connections validate token)
const authServer = withAuth(server, {
provider: jwtProvider,
extractCredentials: (req) => {
const url = new URL(req.url, 'http://localhost')
return url.searchParams.get('token')
},
})
await authServer.start()
// HTTP: http://localhost:8080/api/*
// WebSocket: ws://localhost:8080?token=xxx
```
### Inline Routes
Routes can also be defined directly in configuration (merged with file routes, inline takes priority):
```typescript
const server = await createServer({
port: 8080,
http: {
'/health': {
GET: (req, res) => res.json({ status: 'ok' }),
},
'/webhook': async (req, res) => {
// Accepts all methods
await handleWebhook(req.body)
res.json({ received: true })
},
},
})
```
> For detailed documentation, see [HTTP Routing](/en/modules/network/http)
## Room System
@@ -373,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

@@ -235,3 +235,139 @@ const corrected = prediction.reconcile(serverState, serverSeq, applyInput);
1. **Interpolation delay**: 100-150ms for typical networks
2. **Prediction**: Use only for local player, interpolate remote players
3. **Snapshot count**: Keep enough snapshots to handle network jitter
---
## Fixed-Point Sync (Lockstep)
For **Lockstep** architecture, use fixed-point numbers to ensure cross-platform determinism.
> See [Fixed-Point Numbers](/en/modules/network/fixed-point) for math basics
### FixedTransformState
Fixed-point transform state for network transmission:
```typescript
import {
FixedTransformState,
FixedTransformStateWithVelocity,
type IFixedTransformStateRaw
} from '@esengine/network';
// Create state
const state = FixedTransformState.from(100, 200, Math.PI / 4);
// Serialize (sender)
const raw: IFixedTransformStateRaw = state.toRaw();
socket.send(JSON.stringify({ type: 'sync', state: raw }));
// Deserialize (receiver)
const received = FixedTransformState.fromRaw(message.state);
// Use for rendering
const { x, y, rotation } = received.toFloat();
sprite.position.set(x, y);
```
State with velocity (for extrapolation):
```typescript
const state = FixedTransformStateWithVelocity.from(
100, 200, // position
0, // rotation
5, 3, // velocity
0.1 // angular velocity
);
```
### Fixed-Point Interpolators
```typescript
import {
createFixedTransformInterpolator,
createFixedHermiteTransformInterpolator
} from '@esengine/network';
import { Fixed32 } from '@esengine/ecs-framework-math';
// Linear interpolator
const interpolator = createFixedTransformInterpolator();
const from = FixedTransformState.from(0, 0, 0);
const to = FixedTransformState.from(100, 50, Math.PI);
const t = Fixed32.from(0.5);
const result = interpolator.interpolate(from, to, t);
// Hermite interpolator (smoother)
const hermite = createFixedHermiteTransformInterpolator(100);
```
### Fixed-Point Snapshot Buffer
Manages fixed-point state history for lockstep replay:
```typescript
import {
FixedSnapshotBuffer,
createFixedSnapshotBuffer
} from '@esengine/network';
// Create buffer (max 30 snapshots, 2 frame delay)
const buffer = createFixedSnapshotBuffer<FixedTransformState>(30, 2);
// Add snapshots
buffer.push({
frame: 100,
state: FixedTransformState.from(100, 200, 0)
});
// Get interpolation snapshots
const result = buffer.getInterpolationSnapshots(103);
if (result) {
const { from, to, t } = result;
const interpolated = interpolator.interpolate(from.state, to.state, t);
}
// Get latest/specific frame
const latest = buffer.getLatest();
const atFrame = buffer.getAtFrame(100);
// Rollback replay
const snapshotsToReplay = buffer.getSnapshotsAfter(98);
// Clean up old snapshots
buffer.removeSnapshotsBefore(95);
```
Sub-frame interpolation:
```typescript
// Use Fixed32 frame time (supports fractional frames)
const frameTime = Fixed32.from(102.5);
const result = buffer.getInterpolationSnapshotsFixed(frameTime);
```
### API Exports
```typescript
import {
// State classes
FixedTransformState,
FixedTransformStateWithVelocity,
type IFixedTransformStateRaw,
type IFixedTransformStateWithVelocityRaw,
// Interpolators
FixedTransformInterpolator,
FixedHermiteTransformInterpolator,
createFixedTransformInterpolator,
createFixedHermiteTransformInterpolator,
// Snapshot buffer
FixedSnapshotBuffer,
createFixedSnapshotBuffer,
type IFixedStateSnapshot,
type IFixedInterpolationResult
} from '@esengine/network';
```

View File

@@ -434,6 +434,6 @@ const found = hierarchySystem.findChild(parent, "Child");
## 下一步
- 了解 [实体类](./entity/) 的其他功能
- 了解 [场景管理](./scene/) 如何组织实体和系统
- 了解 [组件系统](./component/) 如何定义和使用组件
- 了解 [实体类](/guide/entity/) 的其他功能
- 了解 [场景管理](/guide/scene/) 如何组织实体和系统
- 了解 [组件系统](/guide/component/) 如何定义和使用组件

View File

@@ -0,0 +1,363 @@
---
title: "持久实体"
---
# 持久实体
> **版本**: v2.3.0+
持久实体是一种特殊类型的实体,在场景切换时会自动迁移到新场景。适用于需要跨场景保持状态的游戏对象,如玩家、游戏管理器、音频管理器等。
## 基本概念
在 ECS 框架中,实体有两种生命周期策略:
| 策略 | 描述 | 默认 |
|------|------|------|
| `SceneLocal` | 场景局部实体,场景切换时销毁 | ✓ |
| `Persistent` | 持久实体,场景切换时自动迁移 | |
## 快速开始
### 创建持久实体
```typescript
import { Scene } from '@esengine/ecs-framework';
class GameScene extends Scene {
protected initialize(): void {
// 创建持久玩家实体
const player = this.createEntity('Player').setPersistent();
player.addComponent(new Position(100, 200));
player.addComponent(new PlayerData('Hero', 500));
// 创建普通敌人实体(场景切换时销毁)
const enemy = this.createEntity('Enemy');
enemy.addComponent(new Position(300, 200));
enemy.addComponent(new EnemyAI());
}
}
```
### 场景切换时的行为
```typescript
import { Core, Scene } from '@esengine/ecs-framework';
// 初始场景
class Level1Scene extends Scene {
protected initialize(): void {
// 玩家 - 持久实体,将迁移到下一个场景
const player = this.createEntity('Player').setPersistent();
player.addComponent(new Position(0, 0));
player.addComponent(new Health(100));
// 敌人 - 场景局部实体,场景切换时销毁
const enemy = this.createEntity('Enemy');
enemy.addComponent(new Position(100, 100));
}
}
// 目标场景
class Level2Scene extends Scene {
protected initialize(): void {
// 新敌人
const enemy = this.createEntity('Boss');
enemy.addComponent(new Position(200, 200));
}
public onStart(): void {
// 玩家已自动迁移到此场景
const player = this.findEntity('Player');
console.log(player !== null); // true
// 位置和生命值数据完整保留
const position = player?.getComponent(Position);
const health = player?.getComponent(Health);
console.log(position?.x, position?.y); // 0, 0
console.log(health?.value); // 100
}
}
// 切换场景
Core.create({ debug: true });
Core.setScene(new Level1Scene());
// 稍后切换到 Level2
Core.loadScene(new Level2Scene());
// 玩家实体自动迁移,敌人实体被销毁
```
## API 参考
### 实体方法
#### setPersistent()
将实体标记为持久实体,防止在场景切换时被销毁。
```typescript
public setPersistent(): this
```
**返回值**: 返回实体本身,支持链式调用
**示例**:
```typescript
const player = scene.createEntity('Player')
.setPersistent();
player.addComponent(new Position(100, 200));
```
#### setSceneLocal()
将实体恢复为场景局部策略(默认)。
```typescript
public setSceneLocal(): this
```
**返回值**: 返回实体本身,支持链式调用
**示例**:
```typescript
// 动态取消持久性
player.setSceneLocal();
```
#### isPersistent
检查实体是否为持久实体。
```typescript
public get isPersistent(): boolean
```
**示例**:
```typescript
if (entity.isPersistent) {
console.log('这是一个持久实体');
}
```
#### lifecyclePolicy
获取实体的生命周期策略。
```typescript
public get lifecyclePolicy(): EEntityLifecyclePolicy
```
**示例**:
```typescript
import { EEntityLifecyclePolicy } from '@esengine/ecs-framework';
if (entity.lifecyclePolicy === EEntityLifecyclePolicy.Persistent) {
console.log('持久实体');
}
```
### 场景方法
#### findPersistentEntities()
查找场景中所有持久实体。
```typescript
public findPersistentEntities(): Entity[]
```
**返回值**: 持久实体数组
**示例**:
```typescript
const persistentEntities = scene.findPersistentEntities();
console.log(`场景中有 ${persistentEntities.length} 个持久实体`);
```
#### extractPersistentEntities()
提取并移除场景中所有持久实体(通常由框架内部调用)。
```typescript
public extractPersistentEntities(): Entity[]
```
**返回值**: 被提取的持久实体数组
#### receiveMigratedEntities()
接收迁移的实体(通常由框架内部调用)。
```typescript
public receiveMigratedEntities(entities: Entity[]): void
```
**参数**:
- `entities` - 要接收的实体数组
## 使用场景
### 1. 跨关卡的玩家实体
```typescript
class PlayerSetupScene extends Scene {
protected initialize(): void {
// 玩家在所有关卡中保持状态
const player = this.createEntity('Player').setPersistent();
player.addComponent(new Transform(0, 0));
player.addComponent(new Health(100));
player.addComponent(new Inventory());
player.addComponent(new PlayerStats());
}
}
class Level1 extends Scene { /* ... */ }
class Level2 extends Scene { /* ... */ }
class Level3 extends Scene { /* ... */ }
// 玩家实体在所有关卡之间自动迁移
Core.setScene(new PlayerSetupScene());
// ... 游戏进行
Core.loadScene(new Level1());
// ... 关卡完成
Core.loadScene(new Level2());
// 玩家数据(生命值、背包、属性)完整保留
```
### 2. 全局管理器
```typescript
class BootstrapScene extends Scene {
protected initialize(): void {
// 音频管理器 - 跨场景持久
const audioManager = this.createEntity('AudioManager').setPersistent();
audioManager.addComponent(new AudioController());
// 成就管理器 - 跨场景持久
const achievementManager = this.createEntity('AchievementManager').setPersistent();
achievementManager.addComponent(new AchievementTracker());
// 游戏设置 - 跨场景持久
const settings = this.createEntity('GameSettings').setPersistent();
settings.addComponent(new SettingsData());
}
}
```
### 3. 动态切换持久性
```typescript
class GameScene extends Scene {
protected initialize(): void {
// 初始创建为普通实体
const companion = this.createEntity('Companion');
companion.addComponent(new Transform(0, 0));
companion.addComponent(new CompanionAI());
// 监听招募事件
this.eventSystem.on('companion:recruited', () => {
// 招募后变为持久实体
companion.setPersistent();
console.log('同伴加入队伍,将跟随玩家跨场景');
});
// 监听解散事件
this.eventSystem.on('companion:dismissed', () => {
// 解散后恢复为场景局部实体
companion.setSceneLocal();
console.log('同伴离开队伍,不再跨场景持久');
});
}
}
```
## 最佳实践
### 1. 明确标识持久实体
```typescript
// 推荐:创建时立即标记
const player = this.createEntity('Player').setPersistent();
// 不推荐:创建后再标记(容易遗忘)
const player = this.createEntity('Player');
// ... 大量代码 ...
player.setPersistent(); // 容易忘记
```
### 2. 合理使用持久性
```typescript
// ✓ 适合持久化的实体
const player = this.createEntity('Player').setPersistent(); // 玩家
const gameManager = this.createEntity('GameManager').setPersistent(); // 全局管理器
const audioManager = this.createEntity('AudioManager').setPersistent(); // 音频系统
// ✗ 不应该持久化的实体
const bullet = this.createEntity('Bullet'); // 临时对象
const enemy = this.createEntity('Enemy'); // 关卡特定敌人
const particle = this.createEntity('Particle'); // 特效粒子
```
### 3. 检查迁移的实体
```typescript
class NewScene extends Scene {
public onStart(): void {
// 检查预期的持久实体是否存在
const player = this.findEntity('Player');
if (!player) {
console.error('玩家实体未正确迁移!');
// 处理错误情况
}
}
}
```
### 4. 避免循环引用
```typescript
// ✗ 避免:持久实体引用场景局部实体
class BadScene extends Scene {
protected initialize(): void {
const player = this.createEntity('Player').setPersistent();
const enemy = this.createEntity('Enemy');
// 危险player 是持久的但 enemy 不是
// 场景切换后enemy 被销毁,引用变为无效
player.addComponent(new TargetComponent(enemy));
}
}
// ✓ 推荐:使用 ID 引用或事件系统
class GoodScene extends Scene {
protected initialize(): void {
const player = this.createEntity('Player').setPersistent();
const enemy = this.createEntity('Enemy');
// 存储 ID 而非直接引用
player.addComponent(new TargetComponent(enemy.id));
// 或使用事件系统通信
}
}
```
## 重要说明
1. **已销毁的实体不会迁移**:如果实体在场景切换前被销毁,即使标记为持久也不会迁移。
2. **组件数据完整保留**:迁移过程中所有组件及其状态都会被保留。
3. **场景引用会更新**:迁移后,实体的 `scene` 属性将指向新场景。
4. **查询系统会更新**:迁移的实体会自动注册到新场景的查询系统中。
5. **延迟切换同样有效**:使用 `Core.loadScene()` 进行延迟切换时,持久实体同样会迁移。
## 相关文档
- [场景](/guide/scene/) - 了解场景基础知识
- [场景管理器](/guide/scene-manager/) - 了解场景切换

View File

@@ -16,7 +16,7 @@ ECS框架提供了平台适配器接口允许用户为不同的运行环境
## 支持的平台
### 🌐 [浏览器适配器](./platform-adapter/browser/)
### 🌐 [浏览器适配器](/guide/platform-adapter/browser/)
支持所有现代浏览器环境,包括 Chrome、Firefox、Safari、Edge 等。
@@ -30,7 +30,7 @@ ECS框架提供了平台适配器接口允许用户为不同的运行环境
---
### 📱 [微信小游戏适配器](./platform-adapter/wechat-minigame/)
### 📱 [微信小游戏适配器](/guide/platform-adapter/wechat-minigame/)
专为微信小游戏环境设计处理微信小游戏的特殊限制和API。
@@ -44,7 +44,7 @@ ECS框架提供了平台适配器接口允许用户为不同的运行环境
---
### 🖥️ [Node.js适配器](./platform-adapter/nodejs/)
### 🖥️ [Node.js适配器](/guide/platform-adapter/nodejs/)
为 Node.js 服务器环境提供支持,适用于游戏服务器和计算服务器。

View File

@@ -0,0 +1,439 @@
---
title: "场景管理器"
---
# SceneManager
SceneManager 是 ECS Framework 提供的轻量级场景管理器,适用于 95% 的游戏应用。它提供简单直观的 API支持场景切换和延迟加载。
## 适用场景
SceneManager 适用于:
- 单人游戏
- 简单多人游戏
- 移动游戏
- 需要场景切换的游戏(菜单、游戏、暂停等)
- 不需要多 World 隔离的项目
## 功能特性
- 轻量级,零额外开销
- 简单直观的 API
- 支持延迟场景切换(避免在帧中途切换)
- 自动 ECS 流式 API 管理
- 自动场景生命周期处理
- 与 Core 集成,自动更新
- 支持 [持久实体](/guide/persistent-entity/) 跨场景迁移v2.3.0+
## 基本用法
### 推荐:使用 Core 的静态方法
这是最简单且推荐的方式,适用于大多数应用:
```typescript
import { Core, Scene } from '@esengine/ecs-framework';
// 1. 初始化 Core
Core.create({ debug: true });
// 2. 创建并设置场景
class GameScene extends Scene {
protected initialize(): void {
this.name = "GameScene";
// 添加系统
this.addSystem(new MovementSystem());
this.addSystem(new RenderSystem());
// 创建初始实体
const player = this.createEntity("Player");
player.addComponent(new Transform(400, 300));
player.addComponent(new Health(100));
}
public onStart(): void {
console.log("游戏场景已启动");
}
}
// 3. 设置场景
Core.setScene(new GameScene());
// 4. 游戏循环Core.update 自动更新场景)
function gameLoop(deltaTime: number) {
Core.update(deltaTime); // 自动更新所有服务和场景
}
// Laya 引擎集成
Laya.timer.frameLoop(1, this, () => {
const deltaTime = Laya.timer.delta / 1000;
Core.update(deltaTime);
});
// Cocos Creator 集成
update(deltaTime: number) {
Core.update(deltaTime);
}
```
### 进阶:直接使用 SceneManager
如果需要更多控制,可以直接使用 SceneManager
```typescript
import { Core, SceneManager, Scene } from '@esengine/ecs-framework';
// 初始化 Core
Core.create({ debug: true });
// 获取 SceneManager已由 Core 自动创建并注册)
const sceneManager = Core.services.resolve(SceneManager);
// 设置场景
const gameScene = new GameScene();
sceneManager.setScene(gameScene);
// 游戏循环(仍然使用 Core.update
function gameLoop(deltaTime: number) {
Core.update(deltaTime); // Core 自动调用 sceneManager.update()
}
```
**重要提示**:无论使用哪种方式,在游戏循环中只需调用 `Core.update()`。它会自动更新 SceneManager 和场景。无需手动调用 `sceneManager.update()`
## 场景切换
### 立即切换
使用 `Core.setScene()``sceneManager.setScene()` 立即切换场景:
```typescript
// 方法 1使用 Core推荐
Core.setScene(new MenuScene());
// 方法 2使用 SceneManager
const sceneManager = Core.services.resolve(SceneManager);
sceneManager.setScene(new MenuScene());
```
### 延迟切换
使用 `Core.loadScene()``sceneManager.loadScene()` 进行延迟场景切换,在下一帧生效:
```typescript
// 方法 1使用 Core推荐
Core.loadScene(new GameOverScene());
// 方法 2使用 SceneManager
const sceneManager = Core.services.resolve(SceneManager);
sceneManager.loadScene(new GameOverScene());
```
在 System 中切换场景时,使用延迟切换:
```typescript
class GameOverSystem extends EntitySystem {
process(entities: readonly Entity[]): void {
const player = entities.find(e => e.name === 'Player');
const health = player?.getComponent(Health);
if (health && health.value <= 0) {
// 延迟切换到游戏结束场景(下一帧生效)
Core.loadScene(new GameOverScene());
// 当前帧继续执行,不会中断当前系统处理
}
}
}
```
## API 参考
### Core 静态方法(推荐)
#### Core.setScene()
立即切换场景。
```typescript
public static setScene<T extends IScene>(scene: T): T
```
**参数**:
- `scene` - 要设置的场景实例
**返回值**:
- 返回设置的场景实例
**示例**:
```typescript
const gameScene = Core.setScene(new GameScene());
console.log(gameScene.name);
```
#### Core.loadScene()
延迟场景加载(下一帧切换)。
```typescript
public static loadScene<T extends IScene>(scene: T): void
```
**参数**:
- `scene` - 要加载的场景实例
**示例**:
```typescript
Core.loadScene(new GameOverScene());
```
#### Core.scene
获取当前活动场景。
```typescript
public static get scene(): IScene | null
```
**返回值**:
- 当前场景实例,如果没有场景则返回 null
**示例**:
```typescript
const currentScene = Core.scene;
if (currentScene) {
console.log(`当前场景: ${currentScene.name}`);
}
```
### SceneManager 方法(进阶)
如果需要直接使用 SceneManager通过服务容器获取
```typescript
const sceneManager = Core.services.resolve(SceneManager);
```
#### setScene()
立即切换场景。
```typescript
public setScene<T extends IScene>(scene: T): T
```
#### loadScene()
延迟场景加载。
```typescript
public loadScene<T extends IScene>(scene: T): void
```
#### currentScene
获取当前场景。
```typescript
public get currentScene(): IScene | null
```
#### hasScene
检查是否有活动场景。
```typescript
public get hasScene(): boolean
```
#### hasPendingScene
检查是否有待处理的场景切换。
```typescript
public get hasPendingScene(): boolean
```
## 最佳实践
### 1. 使用 Core 的静态方法
```typescript
// 推荐:使用 Core 的静态方法
Core.setScene(new GameScene());
Core.loadScene(new MenuScene());
const currentScene = Core.scene;
// 不推荐:除非有特殊需求,否则不要直接使用 SceneManager
const sceneManager = Core.services.resolve(SceneManager);
sceneManager.setScene(new GameScene());
```
### 2. 只调用 Core.update()
```typescript
// 正确:只调用 Core.update()
function gameLoop(deltaTime: number) {
Core.update(deltaTime); // 自动更新所有服务和场景
}
// 错误:不要手动调用 sceneManager.update()
function gameLoop(deltaTime: number) {
Core.update(deltaTime);
sceneManager.update(); // 重复更新,会导致问题!
}
```
### 3. 使用延迟切换避免问题
在 System 中切换场景时,使用 `loadScene()` 而不是 `setScene()`
```typescript
// 推荐:延迟切换
class HealthSystem extends EntitySystem {
process(entities: readonly Entity[]): void {
for (const entity of entities) {
const health = entity.getComponent(Health);
if (health.value <= 0) {
Core.loadScene(new GameOverScene());
// 当前帧继续处理其他实体
}
}
}
}
// 不推荐:立即切换可能导致问题
class HealthSystem extends EntitySystem {
process(entities: readonly Entity[]): void {
for (const entity of entities) {
const health = entity.getComponent(Health);
if (health.value <= 0) {
Core.setScene(new GameOverScene());
// 场景立即切换,当前帧其他实体可能无法正确处理
}
}
}
}
```
### 4. 场景职责分离
每个场景应该只负责一个特定的游戏状态:
```typescript
// 好的设计 - 职责清晰
class MenuScene extends Scene {
// 只处理菜单相关逻辑
}
class GameScene extends Scene {
// 只处理游戏逻辑
}
class PauseScene extends Scene {
// 只处理暂停界面逻辑
}
// 避免这种设计 - 职责混杂
class MegaScene extends Scene {
// 包含菜单、游戏、暂停和所有其他逻辑
}
```
### 5. 资源管理
在场景的 `unload()` 方法中清理资源:
```typescript
class GameScene extends Scene {
private textures: Map<string, any> = new Map();
private sounds: Map<string, any> = new Map();
protected initialize(): void {
this.loadResources();
}
private loadResources(): void {
this.textures.set('player', loadTexture('player.png'));
this.sounds.set('bgm', loadSound('bgm.mp3'));
}
public unload(): void {
// 清理资源
this.textures.clear();
this.sounds.clear();
console.log('场景资源已清理');
}
}
```
### 6. 事件驱动的场景切换
使用事件系统触发场景切换,保持代码解耦:
```typescript
class GameScene extends Scene {
protected initialize(): void {
// 监听场景切换事件
this.eventSystem.on('goto:menu', () => {
Core.loadScene(new MenuScene());
});
this.eventSystem.on('goto:gameover', (data) => {
Core.loadScene(new GameOverScene());
});
}
}
// 在 System 中触发事件
class GameLogicSystem extends EntitySystem {
process(entities: readonly Entity[]): void {
if (levelComplete) {
this.scene.eventSystem.emitSync('goto:gameover', {
score: 1000,
level: 5
});
}
}
}
```
## 架构概览
SceneManager 在 ECS Framework 中的位置:
```
Core全局服务
└── SceneManager场景管理自动更新
└── Scene当前场景
├── EntitySystem系统
├── Entity实体
└── Component组件
```
## 与 WorldManager 的比较
| 特性 | SceneManager | WorldManager |
|------|--------------|--------------|
| 适用场景 | 95% 的游戏应用 | 高级多世界隔离场景 |
| 复杂度 | 简单 | 复杂 |
| 场景数量 | 单场景(可切换) | 多个 World每个包含多个场景 |
| 性能开销 | 最小 | 较高 |
| 使用方式 | `Core.setScene()` | `worldManager.createWorld()` |
**何时使用 SceneManager**
- 单人游戏
- 简单多人游戏
- 移动游戏
- 需要切换但不需要同时运行的场景
**何时使用 WorldManager**
- MMO 游戏服务器(每个房间一个 World
- 游戏大厅系统(每个游戏房间完全隔离)
- 需要运行多个完全独立的游戏实例
## 相关文档
- [持久实体](/guide/persistent-entity/) - 了解如何在场景切换时保持实体
SceneManager 为大多数游戏提供了简单而强大的场景管理能力。通过 Core 的静态方法,你可以轻松管理场景切换。

View File

@@ -305,11 +305,11 @@ const tree = BehaviorTreeBuilder.create('Timeout')
### Cocos Creator集成
参见[Cocos Creator集成指南](./cocos-integration/)
参见[Cocos Creator集成指南](/modules/behavior-tree/cocos-integration/)
### LayaAir集成
参见[LayaAir集成指南](./laya-integration/)
参见[LayaAir集成指南](/modules/behavior-tree/laya-integration/)
## 最佳实践
@@ -389,6 +389,6 @@ const tree = BehaviorTreeBuilder.create('AI')
## 下一步
- 查看[自定义节点执行器](./custom-actions/)学习如何创建自定义节点
- 阅读[最佳实践](./best-practices/)了解行为树设计技巧
- 参考[编辑器使用指南](./editor-guide/)学习可视化编辑
- 查看[自定义节点执行器](/modules/behavior-tree/custom-actions/)学习如何创建自定义节点
- 阅读[最佳实践](/modules/behavior-tree/best-practices/)了解行为树设计技巧
- 参考[编辑器使用指南](/modules/behavior-tree/editor-guide/)学习可视化编辑

View File

@@ -503,6 +503,6 @@ console.log(json);
## 下一步
- 学习[Cocos Creator 集成](./cocos-integration/)了解如何在游戏引擎中加载资源
- 查看[自定义节点执行器](./custom-actions/)创建自定义行为
- 阅读[最佳实践](./best-practices/)优化你的行为树设计
- 学习[Cocos Creator 集成](/modules/behavior-tree/cocos-integration/)了解如何在游戏引擎中加载资源
- 查看[自定义节点执行器](/modules/behavior-tree/custom-actions/)创建自定义行为
- 阅读[最佳实践](/modules/behavior-tree/best-practices/)优化你的行为树设计

View File

@@ -26,7 +26,7 @@ Root Selector
### 2. 单一职责原则
每个节点应该只做一件事。要实现复杂动作,创建自定义执行器,参见[自定义节点执行器](./custom-actions/)。
每个节点应该只做一件事。要实现复杂动作,创建自定义执行器,参见[自定义节点执行器](/modules/behavior-tree/custom-actions/)。
```typescript
// 好的设计 - 使用内置节点
@@ -465,6 +465,6 @@ export class SmartUpdate implements INodeExecutor {
## 下一步
- 学习[自定义节点执行器](./custom-actions/)扩展行为树功能
- 探索[高级用法](./advanced-usage/)了解更多技巧
- 参考[核心概念](./core-concepts/)深入理解原理
- 学习[自定义节点执行器](/modules/behavior-tree/custom-actions/)扩展行为树功能
- 探索[高级用法](/modules/behavior-tree/advanced-usage/)了解更多技巧
- 参考[核心概念](/modules/behavior-tree/core-concepts/)深入理解原理

View File

@@ -8,7 +8,7 @@ title: "Cocos Creator 集成"
- Cocos Creator 3.x 或更高版本
- 基本的 TypeScript 知识
- 已完成[快速开始](./getting-started/)教程
- 已完成[快速开始](/modules/behavior-tree/getting-started/)教程
## 安装
@@ -679,7 +679,7 @@ const updateInterval = sys.isNative ? 0.016 : 0.05;
## 下一步
- 查看[资产管理](./asset-management/)了解如何加载和管理行为树资产、使用子树
- 学习[高级用法](./advanced-usage/)了解性能优化和调试技巧
- 阅读[最佳实践](./best-practices/)优化你的 AI
- 学习[自定义节点执行器](./custom-actions/)创建自定义行为
- 查看[资产管理](/modules/behavior-tree/asset-management/)了解如何加载和管理行为树资产、使用子树
- 学习[高级用法](/modules/behavior-tree/advanced-usage/)了解性能优化和调试技巧
- 阅读[最佳实践](/modules/behavior-tree/best-practices/)优化你的 AI
- 学习[自定义节点执行器](/modules/behavior-tree/custom-actions/)创建自定义行为

View File

@@ -192,7 +192,7 @@ const tree = BehaviorTreeBuilder.create('Actions')
.build();
```
要实现自定义动作,需要创建自定义执行器,参见[自定义节点执行器](./custom-actions/)。
要实现自定义动作,需要创建自定义执行器,参见[自定义节点执行器](/modules/behavior-tree/custom-actions/)。
#### Condition(条件)
@@ -487,7 +487,7 @@ NodeRuntimeState
现在你已经理解了行为树的核心概念,接下来可以:
- 查看[快速开始](./getting-started/)创建第一个行为树
- 学习[自定义节点执行器](./custom-actions/)创建自定义节点
- 探索[高级用法](./advanced-usage/)了解更多功能
- 阅读[最佳实践](./best-practices/)学习设计模式
- 查看[快速开始](/modules/behavior-tree/getting-started/)创建第一个行为树
- 学习[自定义节点执行器](/modules/behavior-tree/custom-actions/)创建自定义节点
- 探索[高级用法](/modules/behavior-tree/advanced-usage/)了解更多功能
- 阅读[最佳实践](/modules/behavior-tree/best-practices/)学习设计模式

View File

@@ -1123,6 +1123,6 @@ execute(context: NodeExecutionContext): TaskStatus {
## 下一步
- 学习[编辑器工作流](./editor-workflow/)了解如何在编辑器中使用自定义节点
- 阅读[最佳实践](./best-practices/)学习行为树设计模式
- 查看[高级用法](./advanced-usage/)了解更多功能
- 学习[编辑器工作流](/modules/behavior-tree/editor-workflow/)了解如何在编辑器中使用自定义节点
- 阅读[最佳实践](/modules/behavior-tree/best-practices/)学习行为树设计模式
- 查看[高级用法](/modules/behavior-tree/advanced-usage/)了解更多功能

View File

@@ -117,5 +117,5 @@ BehaviorTreeStarter.start(entity, tree);
## 下一步
- 查看[编辑器工作流](./editor-workflow/)了解完整的开发流程
- 查看[自定义节点执行器](./custom-actions/)学习如何扩展节点
- 查看[编辑器工作流](/modules/behavior-tree/editor-workflow/)了解完整的开发流程
- 查看[自定义节点执行器](/modules/behavior-tree/custom-actions/)学习如何扩展节点

View File

@@ -112,7 +112,7 @@ setInterval(() => {
## 实现自定义执行器
要扩展行为树的功能,需要创建自定义执行器(详见[自定义节点执行器](./custom-actions/)
要扩展行为树的功能,需要创建自定义执行器(详见[自定义节点执行器](/modules/behavior-tree/custom-actions/)
```typescript
import {
@@ -250,6 +250,6 @@ setInterval(() => {
## 下一步
- 查看[自定义节点执行器](./custom-actions/)学习如何创建自定义节点
- 查看[高级用法](./advanced-usage/)了解性能优化等高级特性
- 查看[最佳实践](./best-practices/)优化你的AI设计
- 查看[自定义节点执行器](/modules/behavior-tree/custom-actions/)学习如何创建自定义节点
- 查看[高级用法](/modules/behavior-tree/advanced-usage/)了解性能优化等高级特性
- 查看[最佳实践](/modules/behavior-tree/best-practices/)优化你的AI设计

View File

@@ -333,11 +333,11 @@ BehaviorTreeStarter.restart(entity);
现在你已经创建了第一个行为树,接下来可以:
1. 学习[核心概念](./core-concepts/)深入理解行为树原理
2. 学习[资产管理](./asset-management/)了解如何加载和复用行为树、使用子树
3. 查看[自定义节点执行器](./custom-actions/)学习如何创建自定义节点
4. 根据你的场景查看集成教程:[Cocos Creator](./cocos-integration/) 或 [Node.js](./nodejs-usage.md)
5. 查看[高级用法](./advanced-usage/)了解更多功能
1. 学习[核心概念](/modules/behavior-tree/core-concepts/)深入理解行为树原理
2. 学习[资产管理](/modules/behavior-tree/asset-management/)了解如何加载和复用行为树、使用子树
3. 查看[自定义节点执行器](/modules/behavior-tree/custom-actions/)学习如何创建自定义节点
4. 根据你的场景查看集成教程:[Cocos Creator](/modules/behavior-tree/cocos-integration/) 或 [Node.js](/modules/behavior-tree/nodejs-usage/)
5. 查看[高级用法](/modules/behavior-tree/advanced-usage/)了解更多功能
## 常见问题
@@ -384,4 +384,4 @@ console.log('活动节点:', Array.from(runtime?.activeNodeIds || []));
内置的`executeAction``executeCondition`节点只是占位符。要实现真正的自定义逻辑,你需要创建自定义执行器:
参见[自定义节点执行器](./custom-actions/)学习如何创建。
参见[自定义节点执行器](/modules/behavior-tree/custom-actions/)学习如何创建。

View File

@@ -8,7 +8,7 @@ title: "Laya 引擎集成"
- LayaAir 3.x 或更高版本
- 基本的 TypeScript 知识
- 已完成[快速开始](./getting-started/)教程
- 已完成[快速开始](/modules/behavior-tree/getting-started/)教程
## 安装
@@ -311,5 +311,5 @@ class AIManager {
## 下一步
- 查看[高级用法](./advanced-usage/)
- 学习[最佳实践](./best-practices/)
- 查看[高级用法](/modules/behavior-tree/advanced-usage/)
- 学习[最佳实践](/modules/behavior-tree/best-practices/)

View File

@@ -577,6 +577,6 @@ function loadAIState(entity: Entity, savedState: any) {
## 下一步
- 查看[资产管理](./asset-management/)了解资源加载和子树
- 学习[自定义节点执行器](./custom-actions/)创建自定义行为
- 阅读[最佳实践](./best-practices/)优化你的服务端AI
- 查看[资产管理](/modules/behavior-tree/asset-management/)了解资源加载和子树
- 学习[自定义节点执行器](/modules/behavior-tree/custom-actions/)创建自定义行为
- 阅读[最佳实践](/modules/behavior-tree/best-practices/)优化你的服务端AI

View File

@@ -0,0 +1,383 @@
---
title: "Cocos Creator 蓝图编辑器"
description: "在 Cocos Creator 中使用蓝图可视化脚本系统"
---
本文档介绍如何在 Cocos Creator 项目中安装和使用蓝图可视化脚本编辑器扩展。
## 安装扩展
### 1. 复制扩展到项目
`cocos-node-editor` 扩展复制到你的 Cocos Creator 项目的 `extensions` 目录:
```
your-project/
├── assets/
├── extensions/
│ └── cocos-node-editor/ # 蓝图编辑器扩展
└── ...
```
### 2. 安装依赖
在扩展目录中安装依赖:
```bash
cd extensions/cocos-node-editor
npm install
```
### 3. 启用扩展
1. 打开 Cocos Creator
2. 进入 **扩展 → 扩展管理器**
3. 找到 `cocos-node-editor` 并启用
## 打开蓝图编辑器
通过菜单 **面板 → Node Editor** 打开蓝图编辑器面板。
## 编辑器界面
### 工具栏
| 按钮 | 快捷键 | 功能 |
|------|--------|------|
| 新建 | - | 创建空白蓝图 |
| 加载 | - | 从文件加载蓝图 |
| 保存 | `Ctrl+S` | 保存蓝图到文件 |
| 撤销 | `Ctrl+Z` | 撤销上一步操作 |
| 重做 | `Ctrl+Shift+Z` | 重做操作 |
| 剪切 | `Ctrl+X` | 剪切选中节点 |
| 复制 | `Ctrl+C` | 复制选中节点 |
| 粘贴 | `Ctrl+V` | 粘贴节点 |
| 删除 | `Delete` | 删除选中项 |
| 重新扫描 | - | 重新扫描项目中的蓝图节点 |
### 画布操作
- **右键单击画布**:打开节点添加菜单
- **拖拽节点**:移动节点位置
- **点击节点**:选中节点
- **Ctrl+点击**:多选节点
- **拖拽引脚到引脚**:创建连接
- **滚轮**:缩放画布
- **中键拖拽**:平移画布
### 节点菜单
右键单击画布后会显示节点菜单:
- 顶部搜索框可以快速搜索节点
- 节点按类别分组显示
-`Enter` 快速添加第一个搜索结果
-`Esc` 关闭菜单
## 蓝图文件格式
蓝图保存为 `.blueprint.json` 文件,格式与运行时完全兼容:
```json
{
"version": 1,
"type": "blueprint",
"metadata": {
"name": "My Blueprint",
"createdAt": 1704307200000,
"modifiedAt": 1704307200000
},
"variables": [],
"nodes": [
{
"id": "node-1",
"type": "PrintString",
"position": { "x": 100, "y": 200 },
"data": {}
}
],
"connections": [
{
"id": "conn-1",
"fromNodeId": "node-1",
"fromPin": "exec",
"toNodeId": "node-2",
"toPin": "exec"
}
]
}
```
## 在游戏中运行蓝图
使用 ECS 系统方式管理和执行蓝图。
### 1. 定义蓝图组件
```typescript
import { Component, ECSComponent, Property, Serialize } from '@esengine/ecs-framework';
import type { BlueprintAsset } from '@esengine/blueprint';
@ECSComponent('Blueprint')
export class BlueprintComponent extends Component {
@Serialize()
@Property({ type: 'asset', label: 'Blueprint Asset' })
blueprintPath: string = '';
@Serialize()
@Property({ type: 'boolean', label: 'Auto Start' })
autoStart: boolean = true;
// 运行时数据(不序列化)
blueprintAsset: BlueprintAsset | null = null;
vm: BlueprintVM | null = null;
isStarted: boolean = false;
}
```
### 2. 创建蓝图执行系统
```typescript
import { EntitySystem, Matcher, Entity } from '@esengine/ecs-framework';
import {
BlueprintVM,
validateBlueprintAsset
} from '@esengine/blueprint';
import { BlueprintComponent } from './BlueprintComponent';
export class BlueprintExecutionSystem extends EntitySystem {
constructor() {
super(Matcher.empty().all(BlueprintComponent));
}
protected override process(entities: readonly Entity[]): void {
const dt = Time.deltaTime;
for (const entity of entities) {
const bp = entity.getComponent(BlueprintComponent)!;
// 跳过没有蓝图资产的实体
if (!bp.blueprintAsset) continue;
// 初始化 VM
if (!bp.vm) {
bp.vm = new BlueprintVM(bp.blueprintAsset, entity, this.scene!);
}
// 自动启动
if (bp.autoStart && !bp.isStarted) {
bp.vm.start();
bp.isStarted = true;
}
// 更新蓝图
if (bp.isStarted) {
bp.vm.tick(dt);
}
}
}
protected override onRemoved(entity: Entity): void {
const bp = entity.getComponent(BlueprintComponent);
if (bp?.vm && bp.isStarted) {
bp.vm.stop();
bp.vm = null;
bp.isStarted = false;
}
}
}
```
### 3. 加载蓝图并添加到实体
```typescript
import { resources, JsonAsset } from 'cc';
import { validateBlueprintAsset } from '@esengine/blueprint';
// 加载蓝图资产
async function loadBlueprint(path: string): Promise<BlueprintAsset | null> {
return new Promise((resolve) => {
resources.load(path, JsonAsset, (err, asset) => {
if (err || !asset) {
console.error('Failed to load blueprint:', err);
resolve(null);
return;
}
const data = asset.json;
if (validateBlueprintAsset(data)) {
resolve(data as BlueprintAsset);
} else {
console.error('Invalid blueprint format');
resolve(null);
}
});
});
}
// 创建带蓝图的实体
async function createBlueprintEntity(scene: IScene, blueprintPath: string): Promise<Entity> {
const entity = scene.createEntity('BlueprintEntity');
const bpComponent = entity.addComponent(BlueprintComponent);
bpComponent.blueprintPath = blueprintPath;
bpComponent.blueprintAsset = await loadBlueprint(blueprintPath);
return entity;
}
```
### 4. 注册系统到场景
```typescript
// 在场景初始化时
scene.addSystem(new BlueprintExecutionSystem());
```
## 创建自定义节点
### 使用装饰器标记组件
推荐使用装饰器让组件自动生成蓝图节点:
```typescript
import { Component, ECSComponent } from '@esengine/ecs-framework';
import { BlueprintExpose, BlueprintProperty, BlueprintMethod } from '@esengine/blueprint';
@ECSComponent('Health')
@BlueprintExpose({ displayName: '生命值组件' })
export class HealthComponent extends Component {
@BlueprintProperty({ displayName: '当前生命值', category: 'number' })
current: number = 100;
@BlueprintProperty({ displayName: '最大生命值', category: 'number' })
max: number = 100;
@BlueprintMethod({ displayName: '治疗', isExec: true })
heal(amount: number): void {
this.current = Math.min(this.current + amount, this.max);
}
@BlueprintMethod({ displayName: '受伤', isExec: true })
takeDamage(amount: number): void {
this.current = Math.max(this.current - amount, 0);
}
@BlueprintMethod({ displayName: '是否死亡' })
isDead(): boolean {
return this.current <= 0;
}
}
```
### 注册组件节点
```typescript
import { registerAllComponentNodes } from '@esengine/blueprint';
// 在应用启动时注册所有标记的组件
registerAllComponentNodes();
```
### 手动定义节点(高级)
如需完全自定义节点逻辑:
```typescript
import {
BlueprintNodeTemplate,
INodeExecutor,
RegisterNode,
ExecutionContext,
ExecutionResult
} from '@esengine/blueprint';
const MyNodeTemplate: BlueprintNodeTemplate = {
type: 'MyCustomNode',
title: '我的自定义节点',
category: 'custom',
description: '自定义节点示例',
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' }
]
};
@RegisterNode(MyNodeTemplate)
class MyNodeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = context.getInput<number>(node.id, 'value');
return {
outputs: { result: value * 2 },
nextExec: 'exec'
};
}
}
```
## 节点类别
| 类别 | 说明 | 颜色 |
|------|------|------|
| `event` | 事件节点 | 红色 |
| `flow` | 流程控制 | 灰色 |
| `entity` | 实体操作 | 蓝色 |
| `component` | 组件访问 | 青色 |
| `math` | 数学运算 | 绿色 |
| `logic` | 逻辑运算 | 红色 |
| `variable` | 变量访问 | 紫色 |
| `time` | 时间工具 | 青色 |
| `debug` | 调试工具 | 灰色 |
| `custom` | 自定义节点 | 蓝灰色 |
## 最佳实践
1. **文件组织**
- 将蓝图文件放在 `assets/blueprints/` 目录下
- 使用有意义的文件名,如 `player-controller.blueprint.json`
2. **组件设计**
- 使用 `@BlueprintExpose` 标记需要暴露给蓝图的组件
- 为属性和方法提供清晰的 `displayName`
- 将执行方法标记为 `isExec: true`
3. **性能考虑**
- 避免在 Tick 事件中执行重计算
- 使用变量缓存中间结果
- 纯函数节点会自动缓存输出
4. **调试技巧**
- 使用 Print 节点输出中间值
- 启用 `vm.debug = true` 查看执行日志
## 常见问题
### Q: 节点菜单是空的?
A: 点击 **重新扫描** 按钮扫描项目中的蓝图节点类。确保已调用 `registerAllComponentNodes()`
### Q: 蓝图不执行?
A: 检查:
1. 实体是否添加了 `BlueprintComponent`
2. `BlueprintExecutionSystem` 是否注册到场景
3. `blueprintAsset` 是否正确加载
4. `autoStart` 是否为 `true`
### Q: 如何触发自定义事件?
A: 通过 VM 触发:
```typescript
const bp = entity.getComponent(BlueprintComponent);
bp.vm?.triggerCustomEvent('OnPickup', { item: itemEntity });
```
## 相关文档
- [蓝图运行时 API](/modules/blueprint/) - BlueprintVM 和核心 API
- [自定义节点](/modules/blueprint/custom-nodes) - 详细的节点创建指南
- [内置节点](/modules/blueprint/nodes) - 内置节点参考

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,876 @@
---
title: "蓝图编辑器使用指南"
description: "Cocos Creator 蓝图可视化脚本编辑器完整使用教程"
---
<script src="/js/blueprint-graph.js"></script>
本指南介绍如何在 Cocos Creator 中使用蓝图可视化脚本编辑器。
## 下载与安装
### 下载
从 GitHub Release 下载最新版本(免费):
**[下载 Cocos Node Editor v1.1.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.1.0)**
> 技术交流 QQ 群:**481923584** | 官网:[esengine.cn](https://esengine.cn/)
### 安装步骤
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 |
**示例:游戏开始时打印消息**
<div class="bp-graph" style="" data-connections='[{"from":"eg1-exec","to":"eg1-print","type":"exec"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event BeginPlay</span>
<span class="bp-header-exec" data-pin="eg1-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Self</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
<div class="bp-node-header debug">Print</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="eg1-print"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Message</span>
<span class="bp-pin-value">"游戏开始!"</span>
</div>
</div>
</div>
</div>
### 实体节点 (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 |
**示例:创建新实体**
<div class="bp-graph" style="" data-connections='[{"from":"eg2-exec","to":"eg2-create","type":"exec"},{"from":"eg2-create-out","to":"eg2-add","type":"exec"},{"from":"eg2-entity","to":"eg2-add-entity","type":"entity"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event BeginPlay</span>
<span class="bp-header-exec" data-pin="eg2-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
</div>
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
<div class="bp-node-header function">Create Entity</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="eg2-create"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Name</span>
<span class="bp-pin-value">"Bullet"</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="eg2-create-out"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="eg2-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Entity</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 520px; top: 20px; width: 150px;">
<div class="bp-node-header function">Add Transform</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="eg2-add"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="eg2-add-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Entity</span>
</div>
</div>
</div>
</div>
### 组件节点 (Component)
访问和操作 ECS 组件。
| 节点 | 功能 |
|------|------|
| **Has Component** | 检查实体是否有指定组件 |
| **Get Component** | 获取组件实例 |
| **Add Component** | 添加组件到实体 |
| **Remove Component** | 移除组件 |
| **Get/Set Property** | 获取/设置组件属性 |
**示例:修改 Transform 组件**
<div class="bp-graph" style="" data-connections='[{"from":"eg3-self","to":"eg3-getcomp","type":"entity"},{"from":"eg3-comp","to":"eg3-setprop","type":"component"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 100px;">
<div class="bp-node-header pure">Get Self</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="eg3-self"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Entity</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 200px; top: 20px; width: 150px;">
<div class="bp-node-header pure">Get Component</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="eg3-getcomp"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Entity</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="eg3-comp"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
<span class="bp-pin-label">Transform</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 430px; top: 20px; width: 130px;">
<div class="bp-node-header function">Set Property</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="eg3-setprop"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
<span class="bp-pin-label">Target</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">x</span>
<span class="bp-pin-value">100</span>
</div>
</div>
</div>
</div>
### 流程控制节点 (Flow)
控制执行流程的节点。
#### Branch (分支)
条件判断,类似 if/else。
<div class="bp-graph" style="" data-connections='[{"from":"eg4-true","to":"eg4-do1","type":"exec"},{"from":"eg4-false","to":"eg4-do2","type":"exec"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 50px; width: 110px;">
<div class="bp-node-header flow">Branch</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#8c0000" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Condition</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="eg4-true"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">True</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="eg4-false"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">False</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 220px; top: 20px; width: 130px;">
<div class="bp-node-header function">DoSomething</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="eg4-do1"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 220px; top: 110px; width: 130px;">
<div class="bp-node-header function">DoOtherThing</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="eg4-do2"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
</div>
</div>
</div>
#### Sequence (序列)
按顺序执行多个分支。
<div class="bp-graph" style="" data-connections='[{"from":"eg5-then0","to":"eg5-step1","type":"exec"},{"from":"eg5-then1","to":"eg5-step2","type":"exec"},{"from":"eg5-then2","to":"eg5-step3","type":"exec"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 110px;">
<div class="bp-node-header flow">Sequence</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="eg5-then0"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Then 0</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="eg5-then1"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Then 1</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="eg5-then2"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Then 2</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 220px; top: 20px; width: 100px;">
<div class="bp-node-header function">Step 1</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="eg5-step1"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 220px; top: 100px; width: 100px;">
<div class="bp-node-header function">Step 2</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="eg5-step2"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 220px; top: 180px; width: 100px;">
<div class="bp-node-header function">Step 3</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="eg5-step3"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
</div>
</div>
</div>
#### For Loop (循环)
循环执行指定次数。
<div class="bp-graph" style="" data-connections='[{"from":"eg6-body","to":"eg6-iter","type":"exec"},{"from":"eg6-done","to":"eg6-finish","type":"exec"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
<div class="bp-node-header flow">For Loop</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
<span class="bp-pin-label">First Index</span>
<span class="bp-pin-value">0</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Last Index</span>
<span class="bp-pin-value">10</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="eg6-body"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Loop Body</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
<span class="bp-pin-label">Index</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="eg6-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Completed</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 250px; top: 80px; width: 130px;">
<div class="bp-node-header function">每次迭代执行</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="eg6-iter"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 250px; top: 160px; width: 140px;">
<div class="bp-node-header function">循环结束后执行</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="eg6-finish"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
</div>
</div>
</div>
#### 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 秒后执行**
<div class="bp-graph" style="" data-connections='[{"from":"eg7-exec","to":"eg7-delay","type":"exec"},{"from":"eg7-done","to":"eg7-print","type":"exec"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event BeginPlay</span>
<span class="bp-header-exec" data-pin="eg7-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
</div>
<div class="bp-node" style="left: 280px; top: 20px; width: 120px;">
<div class="bp-node-header time">Delay</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="eg7-delay"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Duration</span>
<span class="bp-pin-value">2.0</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="eg7-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Done</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 490px; top: 20px; width: 130px;">
<div class="bp-node-header debug">Print</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="eg7-print"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Msg</span>
<span class="bp-pin-value">"2秒后执行"</span>
</div>
</div>
</div>
</div>
### 数学节点 (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移动控制
实现每帧移动实体:
<div class="bp-graph" style="" data-connections='[{"from":"ex1-exec","to":"ex1-setprop","type":"exec"},{"from":"ex1-delta","to":"ex1-mul-a","type":"float"},{"from":"ex1-mul-result","to":"ex1-x","type":"float"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event Tick</span>
<span class="bp-header-exec" data-pin="ex1-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="ex1-delta"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">Delta Time</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 200px; top: 110px; width: 120px;">
<div class="bp-node-header math">Multiply</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="ex1-mul-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">A</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">B (Speed)</span>
<span class="bp-pin-value">100</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="ex1-mul-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 380px; top: 20px; width: 150px;">
<div class="bp-node-header function">Set Property</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="ex1-setprop"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7030c0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Target</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="ex1-x"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">x</span>
</div>
</div>
</div>
</div>
### 示例 2生命值系统
受伤后检查死亡逻辑。`Event OnDamage` 是一个自定义事件节点,可以通过代码 `vm.triggerCustomEvent('OnDamage', { damage: 50 })` 触发:
<div class="bp-graph" data-graph='{
"nodes": [
{
"id": "event", "title": "Event OnDamage", "category": "event",
"outputs": [
{"id": "event-exec", "type": "exec", "inHeader": true},
{"id": "event-self", "type": "entity", "label": "Self"},
{"id": "event-damage", "type": "float", "label": "Damage"}
]
},
{
"id": "getcomp", "title": "Get Component", "category": "function",
"inputs": [
{"id": "getcomp-exec", "type": "exec", "label": "Exec"},
{"id": "getcomp-entity", "type": "entity", "label": "Entity"},
{"id": "getcomp-type", "type": "string", "label": "Type", "value": "Health", "connected": false}
],
"outputs": [
{"id": "getcomp-out", "type": "exec"},
{"id": "getcomp-comp", "type": "component", "label": "Component"}
]
},
{
"id": "getprop", "title": "Get Property", "category": "pure",
"inputs": [
{"id": "getprop-target", "type": "component", "label": "Target"},
{"id": "getprop-prop", "type": "string", "label": "Property", "value": "current", "connected": false}
],
"outputs": [
{"id": "getprop-val", "type": "float", "label": "Value"}
]
},
{
"id": "sub", "title": "Subtract", "category": "math",
"inputs": [
{"id": "sub-exec", "type": "exec", "label": "Exec"},
{"id": "sub-a", "type": "float", "label": "A"},
{"id": "sub-b", "type": "float", "label": "B"}
],
"outputs": [
{"id": "sub-out", "type": "exec"},
{"id": "sub-result", "type": "float", "label": "Result"}
]
},
{
"id": "setprop", "title": "Set Property", "category": "function",
"inputs": [
{"id": "setprop-exec", "type": "exec", "label": "Exec"},
{"id": "setprop-target", "type": "component", "label": "Target"},
{"id": "setprop-prop", "type": "string", "label": "Property", "value": "current", "connected": false},
{"id": "setprop-val", "type": "float", "label": "Value"}
],
"outputs": [
{"id": "setprop-out", "type": "exec"}
]
},
{
"id": "lte", "title": "Less Or Equal", "category": "pure",
"inputs": [
{"id": "lte-a", "type": "float", "label": "A"},
{"id": "lte-b", "type": "float", "label": "B", "value": "0", "connected": false}
],
"outputs": [
{"id": "lte-result", "type": "bool", "label": "Result"}
]
},
{
"id": "branch", "title": "Branch", "category": "flow",
"inputs": [
{"id": "branch-exec", "type": "exec", "label": "Exec"},
{"id": "branch-cond", "type": "bool", "label": "Condition"}
],
"outputs": [
{"id": "branch-true", "type": "exec", "label": "True"},
{"id": "branch-false", "type": "exec", "label": "False"}
]
},
{
"id": "destroy", "title": "Destroy Entity", "category": "function",
"inputs": [
{"id": "destroy-exec", "type": "exec", "label": "Exec"},
{"id": "destroy-entity", "type": "entity", "label": "Entity"}
]
}
],
"connections": [
{"from": "event-exec", "to": "getcomp-exec", "type": "exec"},
{"from": "getcomp-out", "to": "sub-exec", "type": "exec"},
{"from": "sub-out", "to": "setprop-exec", "type": "exec"},
{"from": "setprop-out", "to": "branch-exec", "type": "exec"},
{"from": "branch-true", "to": "destroy-exec", "type": "exec"},
{"from": "event-self", "to": "getcomp-entity", "type": "entity"},
{"from": "event-self", "to": "destroy-entity", "type": "entity"},
{"from": "getcomp-comp", "to": "getprop-target", "type": "component"},
{"from": "getcomp-comp", "to": "setprop-target", "type": "component"},
{"from": "getprop-val", "to": "sub-a", "type": "float"},
{"from": "event-damage", "to": "sub-b", "type": "float"},
{"from": "sub-result", "to": "setprop-val", "type": "float"},
{"from": "sub-result", "to": "lte-a", "type": "float"},
{"from": "lte-result", "to": "branch-cond", "type": "bool"}
]
}'></div>
### 示例 3延迟生成
每 2 秒生成一个敌人:
<div class="bp-graph" style="" data-connections='[{"from":"ex3-begin-exec","to":"ex3-loop","type":"exec"},{"from":"ex3-loop-body","to":"ex3-delay","type":"exec"},{"from":"ex3-delay-done","to":"ex3-create","type":"exec"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event BeginPlay</span>
<span class="bp-header-exec" data-pin="ex3-begin-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
</div>
<div class="bp-node" style="left: 240px; top: 20px; width: 130px;">
<div class="bp-node-header flow">Do N Times</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="ex3-loop"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
<span class="bp-pin-label">N</span>
<span class="bp-pin-value">10</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="ex3-loop-body"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Loop Body</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
<span class="bp-pin-label">Index</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Completed</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 430px; top: 20px; width: 120px;">
<div class="bp-node-header time">Delay</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="ex3-delay"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Duration</span>
<span class="bp-pin-value">2.0</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="ex3-delay-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Done</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 610px; top: 20px; width: 140px;">
<div class="bp-node-header function">Create Entity</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="ex3-create"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Name</span>
<span class="bp-pin-value">"Enemy"</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Entity</span>
</div>
</div>
</div>
</div>
## 常见问题
### Q: 节点无法连接?
检查引脚类型是否匹配。执行引脚(白色)只能连接执行引脚,数据引脚需要类型兼容。
### Q: 蓝图不执行?
1. 确保实体添加了 `BlueprintComponent`
2. 确保场景添加了 `BlueprintSystem`
3. 检查 `autoStart` 是否为 `true`
### Q: 如何调试?
使用 **Print** 节点输出变量值到控制台。
## 下一步
- [ECS 节点参考](/modules/blueprint/nodes) - 完整节点列表
- [自定义节点](/modules/blueprint/custom-nodes) - 创建自定义节点
- [运行时集成](/modules/blueprint/vm) - 蓝图虚拟机 API
- [实际示例](/modules/blueprint/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,162 @@
---
title: "蓝图可视化脚本 (Blueprint)"
description: "完整的可视化脚本系统"
description: "与 ECS 框架深度集成的可视化脚本系统"
---
`@esengine/blueprint` 提供了一个功能完整的可视化脚本系统,支持节点式编程、事件驱动和蓝图组合
`@esengine/blueprint` 提供与 ECS 框架深度集成的可视化脚本系统,支持通过节点式编程控制实体行为
## 安装
## 编辑器下载
Cocos Creator 蓝图编辑器插件(免费):
**[下载 Cocos Node Editor v1.1.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.1.0)**
> 技术交流 QQ 群:**481923584** | 官网:[esengine.cn](https://esengine.cn/)
详细使用教程请参考 [编辑器使用指南](./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,540 @@
---
title: "内置节点"
description: "蓝图内置节点参考"
title: "ECS 节点参考"
description: "蓝图内置 ECS 操作节点完整参考"
---
本文档提供蓝图系统所有内置节点的完整参考,包含可视化示例。
## 引脚类型说明
<div class="bp-legend">
<div class="bp-legend-item"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff" stroke="#fff" stroke-width="1"/></svg> 执行流 (Exec)</div>
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#00a0e0" stroke-width="2"/></svg> 实体 (Entity)</div>
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#7030c0" stroke-width="2"/></svg> 组件 (Component)</div>
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#7ecd32" stroke-width="2"/></svg> 数值 (Float)</div>
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#e060e0" stroke-width="2"/></svg> 字符串 (String)</div>
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#8c0000" stroke-width="2"/></svg> 布尔 (Boolean)</div>
</div>
## 事件节点
| 节点 | 说明 |
|------|------|
| `EventBeginPlay` | 蓝图启动时触发 |
| `EventTick` | 每帧触发 |
| `EventEndPlay` | 蓝图停止时触发 |
| `EventCollision` | 碰撞时触发 |
| `EventInput` | 输入事件触发 |
| `EventTimer` | 定时器触发 |
| `EventMessage` | 自定义消息触发 |
生命周期事件,作为蓝图执行的入口点:
| 节点 | 说明 | 输出 |
|------|------|------|
| `EventBeginPlay` | 蓝图启动时触发 | Exec, Self (Entity) |
| `EventTick` | 每帧触发 | Exec, Delta Time |
| `EventEndPlay` | 蓝图停止时触发 | Exec |
### 示例:游戏初始化
<div class="bp-graph" style="" data-connections='[{"from":"beginplay-exec","to":"print-exec","type":"exec"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event BeginPlay</span>
<span class="bp-header-exec" data-pin="beginplay-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Self</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 280px; top: 20px; width: 170px;">
<div class="bp-node-header debug">Print</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="print-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Message</span>
<span class="bp-pin-value">"游戏开始!"</span>
</div>
</div>
</div>
</div>
### 示例:每帧移动
<div class="bp-graph" style="" data-connections='[{"from":"tick-exec","to":"setprop-exec","type":"exec"},{"from":"tick-delta","to":"mul-a","type":"float"},{"from":"mul-result","to":"setprop-x","type":"float"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event Tick</span>
<span class="bp-header-exec" data-pin="tick-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="tick-delta"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">Delta Time</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 200px; top: 110px; width: 120px;">
<div class="bp-node-header math">Multiply</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="mul-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">A</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">B</span>
<span class="bp-pin-value">100</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="mul-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 380px; top: 20px; width: 150px;">
<div class="bp-node-header function">Set Property</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="setprop-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7030c0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Target</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="setprop-x"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">x</span>
</div>
</div>
</div>
</div>
## 实体节点 (Entity)
操作 ECS 实体:
| 节点 | 说明 | 类型 |
|------|------|------|
| `Get Self` | 获取拥有此蓝图的实体 | 纯节点 |
| `Create Entity` | 在场景中创建新实体 | 执行节点 |
| `Destroy Entity` | 销毁指定实体 | 执行节点 |
| `Destroy Self` | 销毁自身实体 | 执行节点 |
| `Is Valid` | 检查实体是否有效 | 纯节点 |
| `Get Entity Name` | 获取实体名称 | 纯节点 |
| `Set Entity Name` | 设置实体名称 | 执行节点 |
| `Find Entity By Name` | 按名称查找实体 | 纯节点 |
| `Find Entities By Tag` | 按标签查找所有实体 | 纯节点 |
### 示例:创建子弹
<div class="bp-graph" style="" data-connections='[{"from":"bp-exec","to":"create-exec","type":"exec"},{"from":"create-exec-out","to":"add-exec","type":"exec"},{"from":"create-entity","to":"add-entity","type":"entity"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event BeginPlay</span>
<span class="bp-header-exec" data-pin="bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Self</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
<div class="bp-node-header function">Create Entity</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="create-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="create-exec-out"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="create-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Entity</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 520px; top: 20px; width: 150px;">
<div class="bp-node-header function">Add Transform</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="add-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="add-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Entity</span>
</div>
</div>
</div>
</div>
## 组件节点 (Component)
读写组件属性:
| 节点 | 说明 | 类型 |
|------|------|------|
| `Get Component` | 从实体获取指定类型组件 | 纯节点 |
| `Has Component` | 检查实体是否拥有指定组件 | 纯节点 |
| `Add Component` | 为实体添加组件 | 执行节点 |
| `Remove Component` | 从实体移除组件 | 执行节点 |
| `Get Property` | 获取组件属性值 | 纯节点 |
| `Set Property` | 设置组件属性值 | 执行节点 |
### 示例:修改位置
<div class="bp-graph" style="" data-connections='[{"from":"self-entity","to":"getcomp-entity","type":"entity"},{"from":"getcomp-transform","to":"getprop-target","type":"component"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 100px;">
<div class="bp-node-header pure">Get Self</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="self-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Entity</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 200px; top: 20px; width: 150px;">
<div class="bp-node-header pure">Get Component</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="getcomp-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
<span class="bp-pin-label">Entity</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="getcomp-transform"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
<span class="bp-pin-label">Transform</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 430px; top: 20px; width: 120px;">
<div class="bp-node-header pure">Get Property</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="getprop-target"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
<span class="bp-pin-label">Target</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">x</span>
</div>
</div>
</div>
</div>
## 流程控制节点
控制蓝图执行流程:
| 节点 | 说明 |
|------|------|
| `Branch` | 条件分支 (if/else) |
| `Sequence` | 顺序执行多个输出 |
| `ForLoop` | 循环执行 |
| `WhileLoop` | 条件循环 |
| `DoOnce` | 只执行一次 |
| `FlipFlop` | 交替执行两个分支 |
| `Gate` | 可开关的执行门 |
| `Branch` | 条件分支if/else |
| `Sequence` | 顺序执行多个分支 |
| `For Loop` | 指定次数循环 |
| `For Each` | 遍历数组元素 |
| `While Loop` | 条件循环 |
| `Do Once` | 仅执行一次 |
| `Flip Flop` | 交替执行 A/B |
| `Gate` | 门控开关 |
### 示例:条件分支
<div class="bp-graph" style="" data-connections='[{"from":"cond-exec","to":"branch-exec","type":"exec"},{"from":"cond-result","to":"branch-cond","type":"bool"},{"from":"branch-true","to":"print1-exec","type":"exec"},{"from":"branch-false","to":"print2-exec","type":"exec"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 60px; width: 120px;">
<div class="bp-node-header pure">Condition</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="cond-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="cond-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#8c0000"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 220px; top: 60px; width: 110px;">
<div class="bp-node-header flow">Branch</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="branch-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="branch-cond"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#8c0000"/></svg></span>
<span class="bp-pin-label">Cond</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="branch-true"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">True</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="branch-false"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">False</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 420px; top: 20px; width: 120px;">
<div class="bp-node-header debug">Print</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="print1-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Msg</span>
<span class="bp-pin-value">"是"</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 420px; top: 130px; width: 120px;">
<div class="bp-node-header debug">Print</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="print2-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Msg</span>
<span class="bp-pin-value">"否"</span>
</div>
</div>
</div>
</div>
### 示例For 循环
<div class="bp-graph" style="" data-connections='[{"from":"forloop-bp-exec","to":"forloop-exec","type":"exec"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event BeginPlay</span>
<span class="bp-header-exec" data-pin="forloop-bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
</div>
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
<div class="bp-node-header flow">For Loop</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="forloop-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
<span class="bp-pin-label">First</span>
<span class="bp-pin-value">0</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Last</span>
<span class="bp-pin-value">10</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Body</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
<span class="bp-pin-label">Index</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Done</span>
</div>
</div>
</div>
</div>
## 时间节点
| 节点 | 说明 |
|------|------|
| `Delay` | 延迟执行 |
| `GetDeltaTime` | 获取帧间隔 |
| `GetTime` | 获取运行时间 |
| `SetTimer` | 设置定时器 |
| `ClearTimer` | 清除定时器 |
| 节点 | 说明 | 输出 |
|------|------|------|
| `Delay` | 延迟执行指定秒数 | Exec |
| `Get Delta Time` | 获取帧间隔时间 | Float |
| `Get Time` | 获取运行时间 | Float |
### 示例:延迟执行
<div class="bp-graph" style="" data-connections='[{"from":"delay-bp-exec","to":"delay-exec","type":"exec"},{"from":"delay-done","to":"delay-print-exec","type":"exec"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
<div class="bp-node-header event">
<span class="bp-node-header-icon"></span>
<span class="bp-node-header-title">Event BeginPlay</span>
<span class="bp-header-exec" data-pin="delay-bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
</div>
</div>
<div class="bp-node" style="left: 280px; top: 20px; width: 120px;">
<div class="bp-node-header time">Delay</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="delay-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Duration</span>
<span class="bp-pin-value">2.0</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="delay-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Done</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 490px; top: 20px; width: 130px;">
<div class="bp-node-header debug">Print</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="delay-print-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
<span class="bp-pin-label">Exec</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Msg</span>
<span class="bp-pin-value">"2秒后"</span>
</div>
</div>
</div>
</div>
## 数学节点
| 节点 | 说明 |
|------|------|
| `Add` | 加法 |
| `Subtract` | 减法 |
| `Multiply` | 乘法 |
| `Divide` | 除法 |
| `Abs` | 绝对值 |
| `Clamp` | 限制范围 |
| `Lerp` | 线性插值 |
| `Min` / `Max` | 最小/最大值 |
| `Sin` / `Cos` | 三角函数 |
| `Sqrt` | 平方根 |
| `Power` | 幂运算 |
### 基础运算
## 逻辑节点
| 节点 | 说明 | 输入 | 输出 |
|------|------|------|------|
| `Add` | 加法 | A, B | A + B |
| `Subtract` | 减法 | A, B | A - B |
| `Multiply` | 乘法 | A, B | A × B |
| `Divide` | 除法 | A, B | A / B |
| `Modulo` | 取模 | A, B | A % B |
### 数学函数
| 节点 | 说明 | 输入 | 输出 |
|------|------|------|------|
| `Abs` | 绝对值 | Value | \|Value\| |
| `Sqrt` | 平方根 | Value | √Value |
| `Pow` | 幂运算 | Base, Exp | Base^Exp |
| `Floor` | 向下取整 | Value | ⌊Value⌋ |
| `Ceil` | 向上取整 | Value | ⌈Value⌉ |
| `Round` | 四舍五入 | Value | round(Value) |
| `Clamp` | 区间钳制 | Value, Min, Max | min(max(V, Min), Max) |
| `Lerp` | 线性插值 | A, B, Alpha | A + (B-A) × Alpha |
| `Min` | 取最小值 | A, B | min(A, B) |
| `Max` | 取最大值 | A, B | max(A, B) |
### 三角函数
| 节点 | 说明 |
|------|------|
| `And` | 逻辑与 |
| `Or` | 逻辑或 |
| `Not` | 逻辑非 |
| `Equal` | 相等比较 |
| `NotEqual` | 不等比较 |
| `Greater` | 大于比较 |
| `Less` | 小于比较 |
| `Sin` | 正弦 |
| `Cos` | 余弦 |
| `Tan` | 正切 |
| `Asin` | 反正弦 |
| `Acos` | 反余弦 |
| `Atan` | 反正切 |
| `Atan2` | 二参数反正切 |
## 向量节点
### 随机数
| 节点 | 说明 |
|------|------|
| `MakeVector2` | 创建 2D 向量 |
| `BreakVector2` | 分解 2D 向量 |
| `VectorAdd` | 向量加法 |
| `VectorSubtract` | 向量减法 |
| `VectorMultiply` | 向量乘法 |
| `VectorLength` | 向量长度 |
| `VectorNormalize` | 向量归一化 |
| `VectorDistance` | 向量距离 |
| 节点 | 说明 | 输入 | 输出 |
|------|------|------|------|
| `Random` | 随机浮点数 [0, 1) | - | Float |
| `Random Range` | 范围内随机数 | Min, Max | Float |
| `Random Int` | 随机整数 | Min, Max | Int |
## 实体节点
### 比较节点
| 节点 | 说明 |
|------|------|
| `GetSelf` | 获取当前实体 |
| `GetComponent` | 获取组件 |
| `HasComponent` | 检查组件 |
| `AddComponent` | 添加组件 |
| `RemoveComponent` | 移除组件 |
| `SpawnEntity` | 创建实体 |
| `DestroyEntity` | 销毁实体 |
| 节点 | 说明 | 输出 |
|------|------|------|
| `Equal` | A == B | Boolean |
| `Not Equal` | A != B | Boolean |
| `Greater` | A > B | Boolean |
| `Greater Or Equal` | A >= B | Boolean |
| `Less` | A < B | Boolean |
| `Less Or Equal` | A <= B | Boolean |
### 示例:钳制数值
<div class="bp-graph" style="" data-connections='[{"from":"rand-result","to":"clamp-value","type":"float"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="left: 20px; top: 20px; width: 130px;">
<div class="bp-node-header math">Random Range</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Min</span>
<span class="bp-pin-value">0</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Max</span>
<span class="bp-pin-value">100</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="rand-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
<div class="bp-node" style="left: 240px; top: 20px; width: 130px;">
<div class="bp-node-header math">Clamp</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="clamp-value"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">Value</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Min</span>
<span class="bp-pin-value">20</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Max</span>
<span class="bp-pin-value">80</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
</div>
## 变量节点
| 节点 | 说明 |
|------|------|
| `GetVariable` | 获取变量值 |
| `SetVariable` | 设置变量值 |
蓝图定义的变量会自动生成 Get 和 Set 节点:
| 节点 | 说明 | 类型 |
|------|------|------|
| `Get <变量名>` | 读取变量值 | 纯节点 |
| `Set <变量名>` | 设置变量值 | 执行节点 |
## 调试节点
| 节点 | 说明 |
|------|------|
| `Print` | 打印到控制台 |
| `DrawDebugLine` | 绘制调试线 |
| `DrawDebugPoint` | 绘制调试点 |
| `Breakpoint` | 调试断点 |
| `Print` | 输出消息到控制台 |
## 相关文档
- [蓝图编辑器指南](/modules/blueprint/editor-guide) - 学习如何使用编辑器
- [自定义节点](/modules/blueprint/custom-nodes) - 创建自定义节点
- [蓝图虚拟机](/modules/blueprint/vm) - 运行时 API

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

@@ -0,0 +1,326 @@
---
title: "定点数"
description: "用于帧同步的确定性定点数数学库"
---
`@esengine/ecs-framework-math` 提供确定性定点数计算,专为**帧同步 (Lockstep)** 设计。定点数在所有平台上保证产生完全相同的计算结果。
## 为什么需要定点数?
浮点数在不同平台上可能产生不同的舍入结果:
```typescript
// 浮点数:不同平台可能得到不同结果
const a = 0.1 + 0.2; // 0.30000000000000004 (某些平台)
// 0.3 (其他平台)
// 定点数:所有平台结果一致
const x = Fixed32.from(0.1);
const y = Fixed32.from(0.2);
const z = x.add(y); // raw = 19661 (所有平台)
```
| 特性 | 浮点数 | 定点数 |
|------|--------|--------|
| 跨平台一致性 | ❌ 可能不同 | ✅ 完全一致 |
| 网络同步模式 | 状态同步 | 帧同步 (Lockstep) |
| 适用游戏类型 | FPS、RPG | RTS、MOBA、格斗 |
## 安装
```bash
npm install @esengine/ecs-framework-math
```
## Fixed32 定点数
Q16.16 格式16 位整数 + 16 位小数,范围 ±32767.99998。
### 创建定点数
```typescript
import { Fixed32 } from '@esengine/ecs-framework-math';
// 从浮点数创建
const speed = Fixed32.from(5.5);
// 从整数创建(无精度损失)
const count = Fixed32.fromInt(10);
// 从原始值创建(网络接收后使用)
const received = Fixed32.fromRaw(360448); // 等于 5.5
// 预定义常量
Fixed32.ZERO // 0
Fixed32.ONE // 1
Fixed32.HALF // 0.5
Fixed32.PI // π
Fixed32.TWO_PI // 2π
Fixed32.HALF_PI // π/2
```
### 基本运算
```typescript
const a = Fixed32.from(10);
const b = Fixed32.from(3);
const sum = a.add(b); // 13
const diff = a.sub(b); // 7
const prod = a.mul(b); // 30
const quot = a.div(b); // 3.333...
const mod = a.mod(b); // 1
const neg = a.neg(); // -10
const abs = neg.abs(); // 10
```
### 比较运算
```typescript
const x = Fixed32.from(5);
const y = Fixed32.from(3);
x.eq(y) // false - 等于
x.ne(y) // true - 不等于
x.lt(y) // false - 小于
x.le(y) // false - 小于等于
x.gt(y) // true - 大于
x.ge(y) // true - 大于等于
x.isZero() // false
x.isPositive() // true
x.isNegative() // false
```
### 数学函数
```typescript
// 平方根(牛顿迭代法,确定性)
const sqrt = Fixed32.sqrt(Fixed32.from(16)); // 4
// 取整
Fixed32.floor(Fixed32.from(3.7)) // 3
Fixed32.ceil(Fixed32.from(3.2)) // 4
Fixed32.round(Fixed32.from(3.5)) // 4
// 范围限制
Fixed32.clamp(value, min, max)
// 线性插值
Fixed32.lerp(from, to, t)
// 最大/最小值
Fixed32.min(a, b)
Fixed32.max(a, b)
```
### 类型转换
```typescript
const value = Fixed32.from(3.14159);
// 转为浮点数(用于渲染)
const float = value.toNumber(); // 3.14159
// 获取原始值(用于网络传输)
const raw = value.toRaw(); // 205887
// 转为整数(向下取整)
const int = value.toInt(); // 3
```
## FixedVector2 定点数向量
不可变的 2D 向量类,所有运算返回新实例。
### 创建向量
```typescript
import { FixedVector2, Fixed32 } from '@esengine/ecs-framework-math';
// 从浮点数创建
const pos = FixedVector2.from(100, 200);
// 从原始值创建(网络接收后使用)
const received = FixedVector2.fromRaw(6553600, 13107200);
// 从 Fixed32 创建
const vec = new FixedVector2(Fixed32.from(10), Fixed32.from(20));
// 预定义常量
FixedVector2.ZERO // (0, 0)
FixedVector2.ONE // (1, 1)
FixedVector2.RIGHT // (1, 0)
FixedVector2.LEFT // (-1, 0)
FixedVector2.UP // (0, 1)
FixedVector2.DOWN // (0, -1)
```
### 向量运算
```typescript
const a = FixedVector2.from(3, 4);
const b = FixedVector2.from(1, 2);
// 基本运算
const sum = a.add(b); // (4, 6)
const diff = a.sub(b); // (2, 2)
const scaled = a.mul(Fixed32.from(2)); // (6, 8)
const divided = a.div(Fixed32.from(2)); // (1.5, 2)
// 向量积
const dot = a.dot(b); // 3*1 + 4*2 = 11
const cross = a.cross(b); // 3*2 - 4*1 = 2
// 长度
const lenSq = a.lengthSquared(); // 25
const len = a.length(); // 5
// 归一化
const norm = a.normalize(); // (0.6, 0.8)
// 距离
const dist = a.distanceTo(b); // sqrt((3-1)² + (4-2)²)
```
### 旋转和角度
```typescript
import { FixedMath } from '@esengine/ecs-framework-math';
const vec = FixedVector2.from(1, 0);
const angle = Fixed32.from(Math.PI / 2); // 90度
// 旋转向量
const rotated = vec.rotate(angle); // (0, 1)
// 围绕点旋转
const center = FixedVector2.from(5, 5);
const around = vec.rotateAround(center, angle);
// 获取向量角度
const vecAngle = vec.angle();
// 两向量夹角
const between = vec.angleTo(other);
// 从角度创建单位向量
const dir = FixedVector2.fromAngle(angle);
// 从极坐标创建
const polar = FixedVector2.fromPolar(length, angle);
```
### 类型转换
```typescript
const pos = FixedVector2.from(100.5, 200.5);
// 转为浮点对象(用于渲染)
const obj = pos.toObject(); // { x: 100.5, y: 200.5 }
// 转为数组
const arr = pos.toArray(); // [100.5, 200.5]
// 获取原始值(用于网络传输)
const raw = pos.toRawObject(); // { x: 6586368, y: 13140992 }
```
## FixedMath 三角函数
使用查找表实现确定性三角函数。
```typescript
import { FixedMath, Fixed32 } from '@esengine/ecs-framework-math';
const angle = Fixed32.from(Math.PI / 6); // 30度
// 三角函数
const sin = FixedMath.sin(angle); // 0.5
const cos = FixedMath.cos(angle); // 0.866
const tan = FixedMath.tan(angle); // 0.577
// 反三角函数
const atan = FixedMath.atan2(y, x);
const asin = FixedMath.asin(value);
const acos = FixedMath.acos(value);
// 角度规范化到 [-π, π]
const normalized = FixedMath.normalizeAngle(angle);
// 角度差(最短路径)
const delta = FixedMath.angleDelta(from, to);
// 角度插值(处理 360° 环绕)
const lerped = FixedMath.lerpAngle(from, to, t);
// 弧度/角度转换
const deg = FixedMath.radToDeg(rad);
const rad = FixedMath.degToRad(deg);
```
## 最佳实践
### 1. 全程使用定点数计算
```typescript
// ✅ 正确:所有游戏逻辑使用定点数
function calculateDamage(baseDamage: Fixed32, multiplier: Fixed32): Fixed32 {
return baseDamage.mul(multiplier);
}
// ❌ 错误:混用浮点数
function calculateDamage(baseDamage: number, multiplier: number): number {
return baseDamage * multiplier; // 可能不一致
}
```
### 2. 只在渲染时转换为浮点数
```typescript
// 游戏逻辑层
const position: FixedVector2 = calculatePosition(input);
// 渲染层
const { x, y } = position.toObject();
sprite.position.set(x, y);
```
### 3. 使用原始值进行网络传输
```typescript
// ✅ 正确:传输整数原始值
const raw = position.toRawObject();
send(JSON.stringify(raw));
// ❌ 错误:传输浮点数
const float = position.toObject();
send(JSON.stringify(float)); // 可能丢失精度
```
### 4. 使用 FixedMath 进行三角运算
```typescript
// ✅ 正确:使用查找表
const direction = FixedVector2.fromAngle(FixedMath.atan2(dy, dx));
// ❌ 错误:使用 Math 库
const angle = Math.atan2(dy.toNumber(), dx.toNumber()); // 不确定
```
## API 导出
```typescript
import {
Fixed32,
FixedVector2,
FixedMath,
type IFixed32,
type IFixedVector2
} from '@esengine/ecs-framework-math';
```
## 相关文档
- [状态同步](/modules/network/sync) - 定点数快照缓冲区
- [客户端预测](/modules/network/prediction) - 定点数客户端预测

View File

@@ -0,0 +1,679 @@
---
title: "HTTP 路由"
description: "HTTP REST API 路由功能,支持与 WebSocket 共用端口"
---
`@esengine/server` 内置了轻量级的 HTTP 路由功能,可以与 WebSocket 服务共用同一端口,方便实现 REST API。
## 快速开始
### 内联路由定义
最简单的方式是在创建服务器时直接定义 HTTP 路由:
```typescript
import { createServer } from '@esengine/server'
const server = await createServer({
port: 3000,
http: {
'/api/health': (req, res) => {
res.json({ status: 'ok', time: Date.now() })
},
'/api/users': {
GET: (req, res) => {
res.json({ users: [] })
},
POST: async (req, res) => {
const body = req.body as { name: string }
res.status(201).json({ id: '1', name: body.name })
}
}
},
cors: true // 启用 CORS
})
await server.start()
```
### 文件路由
对于较大的项目,推荐使用文件路由。创建 `src/http` 目录,每个文件对应一个路由:
```typescript
// src/http/login.ts
import { defineHttp } from '@esengine/server'
interface LoginBody {
username: string
password: string
}
export default defineHttp<LoginBody>({
method: 'POST',
handler(req, res) {
const { username, password } = req.body as LoginBody
// 验证用户...
if (username === 'admin' && password === '123456') {
res.json({ token: 'jwt-token-here', userId: 'user-1' })
} else {
res.error(401, '用户名或密码错误')
}
}
})
```
```typescript
// server.ts
import { createServer } from '@esengine/server'
const server = await createServer({
port: 3000,
httpDir: './src/http', // HTTP 路由目录
httpPrefix: '/api', // 路由前缀
cors: true
})
await server.start()
// 路由: POST /api/login
```
## defineHttp 定义
`defineHttp` 用于定义类型安全的 HTTP 处理器:
```typescript
import { defineHttp } from '@esengine/server'
interface CreateUserBody {
username: string
email: string
password: string
}
export default defineHttp<CreateUserBody>({
// HTTP 方法(默认 POST
method: 'POST',
// 处理函数
handler(req, res) {
const body = req.body as CreateUserBody
// 处理请求...
res.status(201).json({ id: 'new-user-id' })
}
})
```
### 支持的 HTTP 方法
```typescript
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS'
```
## HttpRequest 对象
HTTP 请求对象包含以下属性:
```typescript
interface HttpRequest {
/** 原始 Node.js IncomingMessage */
raw: IncomingMessage
/** HTTP 方法 */
method: string
/** 请求路径 */
path: string
/** 路由参数(从 URL 路径提取,如 /users/:id */
params: Record<string, string>
/** 查询参数 */
query: Record<string, string>
/** 请求头 */
headers: Record<string, string | string[] | undefined>
/** 解析后的请求体 */
body: unknown
/** 客户端 IP */
ip: string
}
```
### 使用示例
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// 获取查询参数
const page = parseInt(req.query.page ?? '1')
const limit = parseInt(req.query.limit ?? '10')
// 获取请求头
const authHeader = req.headers.authorization
// 获取客户端 IP
console.log('Request from:', req.ip)
res.json({ page, limit })
}
})
```
### 请求体解析
请求体会根据 `Content-Type` 自动解析:
- `application/json` - 解析为 JSON 对象
- `application/x-www-form-urlencoded` - 解析为键值对对象
- 其他 - 保持原始字符串
```typescript
export default defineHttp<{ name: string; age: number }>({
method: 'POST',
handler(req, res) {
// body 已自动解析
const { name, age } = req.body as { name: string; age: number }
res.json({ received: { name, age } })
}
})
```
## HttpResponse 对象
HTTP 响应对象提供链式 API
```typescript
interface HttpResponse {
/** 原始 Node.js ServerResponse */
raw: ServerResponse
/** 设置状态码 */
status(code: number): HttpResponse
/** 设置响应头 */
header(name: string, value: string): HttpResponse
/** 发送 JSON 响应 */
json(data: unknown): void
/** 发送文本响应 */
text(data: string): void
/** 发送错误响应 */
error(code: number, message: string): void
}
```
### 使用示例
```typescript
export default defineHttp({
method: 'POST',
handler(req, res) {
// 设置状态码和自定义头
res
.status(201)
.header('X-Custom-Header', 'value')
.json({ created: true })
}
})
```
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// 发送错误响应
res.error(404, '资源不存在')
// 等价于: res.status(404).json({ error: '资源不存在' })
}
})
```
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// 发送纯文本
res.text('Hello, World!')
}
})
```
## 文件路由规范
### 命名转换
文件名会自动转换为路由路径:
| 文件路径 | 路由路径prefix=/api |
|---------|----------------------|
| `login.ts` | `/api/login` |
| `users/profile.ts` | `/api/users/profile` |
| `users/[id].ts` | `/api/users/:id` |
| `game/room/[roomId].ts` | `/api/game/room/:roomId` |
### 动态路由参数
使用 `[param]` 语法定义动态参数:
```typescript
// src/http/users/[id].ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
handler(req, res) {
// 直接从 params 获取路由参数
const { id } = req.params
res.json({ userId: id })
}
})
```
多个参数的情况:
```typescript
// src/http/users/[userId]/posts/[postId].ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
handler(req, res) {
const { userId, postId } = req.params
res.json({ userId, postId })
}
})
```
### 跳过规则
以下文件会被自动跳过:
-`_` 开头的文件(如 `_helper.ts`
- `index.ts` / `index.js` 文件
-`.ts` / `.js` / `.mts` / `.mjs` 文件
### 目录结构示例
```
src/
└── http/
├── _utils.ts # 跳过(下划线开头)
├── index.ts # 跳过index 文件)
├── health.ts # GET /api/health
├── login.ts # POST /api/login
├── register.ts # POST /api/register
└── users/
├── index.ts # 跳过
├── list.ts # GET /api/users/list
└── [id].ts # GET /api/users/:id
```
## CORS 配置
### 快速启用
```typescript
const server = await createServer({
port: 3000,
cors: true // 使用默认配置
})
```
### 自定义配置
```typescript
const server = await createServer({
port: 3000,
cors: {
// 允许的来源
origin: ['http://localhost:5173', 'https://myapp.com'],
// 或使用通配符
// origin: '*',
// origin: true, // 反射请求来源
// 允许的 HTTP 方法
methods: ['GET', 'POST', 'PUT', 'DELETE'],
// 允许的请求头
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
// 是否允许携带凭证cookies
credentials: true,
// 预检请求缓存时间(秒)
maxAge: 86400
}
})
```
### CorsOptions 类型
```typescript
interface CorsOptions {
/** 允许的来源字符串、字符串数组、true反射或 '*' */
origin?: string | string[] | boolean
/** 允许的 HTTP 方法 */
methods?: string[]
/** 允许的请求头 */
allowedHeaders?: string[]
/** 是否允许携带凭证 */
credentials?: boolean
/** 预检请求缓存时间(秒) */
maxAge?: number
}
```
## 路由合并
文件路由和内联路由可以同时使用,内联路由优先级更高:
```typescript
const server = await createServer({
port: 3000,
httpDir: './src/http',
httpPrefix: '/api',
// 内联路由会与文件路由合并
http: {
'/health': (req, res) => res.json({ status: 'ok' }),
'/api/special': (req, res) => res.json({ special: true })
}
})
```
## 与 WebSocket 共用端口
HTTP 路由与 WebSocket 服务自动共用同一端口:
```typescript
const server = await createServer({
port: 3000,
// WebSocket 相关配置
apiDir: './src/api',
msgDir: './src/msg',
// HTTP 相关配置
httpDir: './src/http',
httpPrefix: '/api',
cors: true
})
await server.start()
// 同一端口 3000
// - WebSocket: ws://localhost:3000
// - HTTP API: http://localhost:3000/api/*
```
## 完整示例
### 游戏服务器登录 API
```typescript
// src/http/auth/login.ts
import { defineHttp } from '@esengine/server'
import { createJwtAuthProvider } from '@esengine/server/auth'
interface LoginRequest {
username: string
password: string
}
interface LoginResponse {
token: string
userId: string
expiresAt: number
}
const jwtProvider = createJwtAuthProvider({
secret: process.env.JWT_SECRET!,
expiresIn: 3600
})
export default defineHttp<LoginRequest>({
method: 'POST',
async handler(req, res) {
const { username, password } = req.body as LoginRequest
// 验证用户
const user = await db.users.findByUsername(username)
if (!user || !await verifyPassword(password, user.passwordHash)) {
res.error(401, '用户名或密码错误')
return
}
// 生成 JWT
const token = jwtProvider.sign({
sub: user.id,
name: user.username,
roles: user.roles
})
const response: LoginResponse = {
token,
userId: user.id,
expiresAt: Date.now() + 3600 * 1000
}
res.json(response)
}
})
```
### 游戏数据查询 API
```typescript
// src/http/game/leaderboard.ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
async handler(req, res) {
const limit = parseInt(req.query.limit ?? '10')
const offset = parseInt(req.query.offset ?? '0')
const players = await db.players.findMany({
sort: { score: 'desc' },
limit,
offset
})
res.json({
data: players,
pagination: { limit, offset }
})
}
})
```
## 中间件
### 中间件类型
中间件是在路由处理前后执行的函数:
```typescript
type HttpMiddleware = (
req: HttpRequest,
res: HttpResponse,
next: () => Promise<void>
) => void | Promise<void>
```
### 内置中间件
```typescript
import {
requestLogger,
bodyLimit,
responseTime,
requestId,
securityHeaders
} from '@esengine/server'
const server = await createServer({
port: 3000,
http: { /* ... */ },
// 全局中间件通过 createHttpRouter 配置
})
```
#### requestLogger - 请求日志
```typescript
import { requestLogger } from '@esengine/server'
// 记录请求和响应时间
requestLogger()
// 同时记录请求体
requestLogger({ logBody: true })
```
#### bodyLimit - 请求体大小限制
```typescript
import { bodyLimit } from '@esengine/server'
// 限制请求体为 1MB
bodyLimit(1024 * 1024)
```
#### responseTime - 响应时间头
```typescript
import { responseTime } from '@esengine/server'
// 自动添加 X-Response-Time 响应头
responseTime()
```
#### requestId - 请求 ID
```typescript
import { requestId } from '@esengine/server'
// 自动生成并添加 X-Request-ID 响应头
requestId()
// 自定义头名称
requestId('X-Trace-ID')
```
#### securityHeaders - 安全头
```typescript
import { securityHeaders } from '@esengine/server'
// 添加常用安全响应头
securityHeaders()
// 自定义配置
securityHeaders({
hidePoweredBy: true,
frameOptions: 'DENY',
noSniff: true
})
```
### 自定义中间件
```typescript
import type { HttpMiddleware } from '@esengine/server'
// 认证中间件
const authMiddleware: HttpMiddleware = async (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) {
res.error(401, 'Unauthorized')
return // 不调用 next(),终止请求
}
// 验证 token...
(req as any).userId = 'decoded-user-id'
await next() // 继续执行后续中间件和处理器
}
```
### 使用中间件
#### 使用 createHttpRouter
```typescript
import { createHttpRouter, requestLogger, bodyLimit } from '@esengine/server'
const router = createHttpRouter({
'/api/users': (req, res) => res.json([]),
'/api/admin': {
GET: {
handler: (req, res) => res.json({ admin: true }),
middlewares: [adminAuthMiddleware] // 路由级中间件
}
}
}, {
middlewares: [requestLogger(), bodyLimit(1024 * 1024)], // 全局中间件
timeout: 30000 // 全局超时 30 秒
})
```
## 请求超时
### 全局超时
```typescript
import { createHttpRouter } from '@esengine/server'
const router = createHttpRouter({
'/api/data': async (req, res) => {
// 如果处理超过 30 秒,自动返回 408 Request Timeout
await someSlowOperation()
res.json({ data: 'result' })
}
}, {
timeout: 30000 // 30 秒
})
```
### 路由级超时
```typescript
const router = createHttpRouter({
'/api/quick': (req, res) => res.json({ fast: true }),
'/api/slow': {
POST: {
handler: async (req, res) => {
await verySlowOperation()
res.json({ done: true })
},
timeout: 120000 // 这个路由允许 2 分钟
}
}
}, {
timeout: 10000 // 全局 10 秒(被路由级覆盖)
})
```
## 最佳实践
1. **使用 defineHttp** - 获得更好的类型提示和代码组织
2. **统一错误处理** - 使用 `res.error()` 返回一致的错误格式
3. **启用 CORS** - 前后端分离时必须配置
4. **目录组织** - 按功能模块组织 HTTP 路由文件
5. **验证输入** - 始终验证 `req.body``req.query` 的内容
6. **状态码规范** - 遵循 HTTP 状态码规范200、201、400、401、404、500 等)
7. **使用中间件** - 通过中间件实现认证、日志、限流等横切关注点
8. **设置超时** - 避免慢请求阻塞服务器

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

@@ -252,3 +252,145 @@ if (predictionSystem) {
console.log('Current sequence:', predictionSystem.inputSequence);
}
```
---
## 定点数客户端预测(帧同步)
用于**帧同步 (Lockstep)** 的确定性客户端预测。
> 定点数基础知识请参考 [定点数文档](/modules/network/fixed-point)
### 基本用法
```typescript
import {
FixedClientPrediction,
createFixedClientPrediction,
type IFixedPredictor,
type IFixedStatePositionExtractor
} from '@esengine/network';
import { Fixed32, FixedVector2 } from '@esengine/ecs-framework-math';
// 定义游戏状态
interface GameState {
position: FixedVector2;
velocity: FixedVector2;
}
// 实现预测器(必须使用定点数运算)
const predictor: IFixedPredictor<GameState, PlayerInput> = {
predict(state: GameState, input: PlayerInput, deltaTime: Fixed32): GameState {
const speed = Fixed32.from(100);
const inputVec = FixedVector2.from(input.dx, input.dy);
const velocity = inputVec.normalize().mul(speed);
const displacement = velocity.mul(deltaTime);
return {
position: state.position.add(displacement),
velocity
};
}
};
// 创建预测器
const prediction = createFixedClientPrediction(predictor, {
maxUnacknowledgedInputs: 60,
fixedDeltaTime: Fixed32.from(1 / 60),
reconciliationThreshold: Fixed32.from(0.001),
enableSmoothReconciliation: false // 帧同步通常关闭
});
```
### 记录输入
```typescript
function onUpdate(input: PlayerInput, currentState: GameState) {
// 记录输入并获得预测状态
const predicted = prediction.recordInput(input, currentState);
// 渲染预测状态
const pos = predicted.position.toObject();
sprite.position.set(pos.x, pos.y);
// 发送输入
socket.send(JSON.stringify({
frame: prediction.currentFrame,
input
}));
}
```
### 服务器校正
```typescript
// 位置提取器
const posExtractor: IFixedStatePositionExtractor<GameState> = {
getPosition(state: GameState): FixedVector2 {
return state.position;
}
};
// 收到服务器状态
function onServerState(serverState: GameState, serverFrame: number) {
const reconciled = prediction.reconcile(
serverState,
serverFrame,
posExtractor
);
}
```
### 回滚重播
```typescript
// 发现不同步时回滚
const correctedState = prediction.rollbackAndResimulate(
serverFrame,
authoritativeState
);
// 查看历史状态
const historicalState = prediction.getStateAtFrame(100);
```
### 预设移动预测器
```typescript
import {
createFixedMovementPredictor,
createFixedMovementPositionExtractor,
type IFixedMovementInput,
type IFixedMovementState
} from '@esengine/network';
// 创建移动预测器(速度 100 单位/秒)
const movePredictor = createFixedMovementPredictor(Fixed32.from(100));
const posExtractor = createFixedMovementPositionExtractor();
const prediction = createFixedClientPrediction<IFixedMovementState, IFixedMovementInput>(
movePredictor,
{ fixedDeltaTime: Fixed32.from(1 / 60) }
);
// 输入格式
const input: IFixedMovementInput = { dx: 1, dy: 0 };
```
### API 导出
```typescript
import {
FixedClientPrediction,
createFixedClientPrediction,
createFixedMovementPredictor,
createFixedMovementPositionExtractor,
type IFixedInputSnapshot,
type IFixedPredictedState,
type IFixedPredictor,
type IFixedStatePositionExtractor,
type FixedClientPredictionConfig,
type IFixedMovementInput,
type IFixedMovementState
} from '@esengine/network';
```

View File

@@ -90,128 +90,35 @@ await server.start()
支持 HTTP API 与 WebSocket 共用端口,适用于登录、注册等场景。
### 文件路由
`httpDir` 目录下创建路由文件,自动映射为 HTTP 端点:
```typescript
const server = await createServer({
port: 3000,
httpDir: './src/http', // HTTP 路由目录
httpPrefix: '/api', // 路由前缀
cors: true,
// 或内联定义
http: {
'/health': (req, res) => res.json({ status: 'ok' })
}
})
```
src/http/
├── login.ts → POST /api/login
├── register.ts → POST /api/register
├── health.ts → GET /api/health (需设置 method: 'GET')
└── users/
└── [id].ts → POST /api/users/:id (动态路由)
```
### 定义路由
使用 `defineHttp` 定义类型安全的路由处理器:
```typescript
// src/http/login.ts
import { defineHttp } from '@esengine/server'
interface LoginBody {
username: string
password: string
}
export default defineHttp<LoginBody>({
method: 'POST', // 默认 POST可选 GET/PUT/DELETE/PATCH
export default defineHttp<{ username: string; password: string }>({
method: 'POST',
handler(req, res) {
const { username, password } = req.body
// 验证凭证...
if (!isValid(username, password)) {
res.error(401, 'Invalid credentials')
return
}
// 生成 token...
res.json({ token: '...', userId: '...' })
// 验证并返回 token...
res.json({ token: '...' })
}
})
```
### 请求对象 (HttpRequest)
```typescript
interface HttpRequest {
raw: IncomingMessage // Node.js 原始请求
method: string // 请求方法
path: string // 请求路径
query: Record<string, string> // 查询参数
headers: Record<string, string | string[] | undefined>
body: unknown // 解析后的 JSON 请求体
ip: string // 客户端 IP
}
```
### 响应对象 (HttpResponse)
```typescript
interface HttpResponse {
raw: ServerResponse // Node.js 原始响应
status(code: number): HttpResponse // 设置状态码(链式)
header(name: string, value: string): HttpResponse // 设置头(链式)
json(data: unknown): void // 发送 JSON
text(data: string): void // 发送文本
error(code: number, message: string): void // 发送错误
}
```
### 使用示例
```typescript
// 完整的登录服务器示例
import { createServer, defineHttp } from '@esengine/server'
import { createJwtAuthProvider, withAuth } from '@esengine/server/auth'
const jwtProvider = createJwtAuthProvider({
secret: process.env.JWT_SECRET!,
expiresIn: 3600 * 24,
})
const server = await createServer({
port: 8080,
httpDir: 'src/http',
httpPrefix: '/api',
cors: true,
})
// 包装认证WebSocket 连接验证 token
const authServer = withAuth(server, {
provider: jwtProvider,
extractCredentials: (req) => {
const url = new URL(req.url, 'http://localhost')
return url.searchParams.get('token')
},
})
await authServer.start()
// HTTP: http://localhost:8080/api/*
// WebSocket: ws://localhost:8080?token=xxx
```
### 内联路由
也可以直接在配置中定义路由(与文件路由合并,内联优先):
```typescript
const server = await createServer({
port: 8080,
http: {
'/health': {
GET: (req, res) => res.json({ status: 'ok' }),
},
'/webhook': async (req, res) => {
// 接受所有方法
await handleWebhook(req.body)
res.json({ received: true })
},
},
})
```
> 详细文档请参考 [HTTP 路由](/modules/network/http)
## Room 系统
@@ -373,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

@@ -340,3 +340,139 @@ if (!identity.bIsLocalPlayer) {
3. **校正阈值**:根据游戏精度需求设置合适的阈值
4. **快照数量**:保持足够的快照以应对网络抖动
---
## 定点数同步(帧同步)
以下内容用于**帧同步 (Lockstep)** 架构,使用定点数确保跨平台确定性。
> 定点数基础知识请参考 [定点数文档](/modules/network/fixed-point)
### FixedTransformState
定点数变换状态,用于网络传输:
```typescript
import {
FixedTransformState,
FixedTransformStateWithVelocity,
type IFixedTransformStateRaw
} from '@esengine/network';
// 创建状态
const state = FixedTransformState.from(100, 200, Math.PI / 4);
// 序列化(发送方)
const raw: IFixedTransformStateRaw = state.toRaw();
socket.send(JSON.stringify({ type: 'sync', state: raw }));
// 反序列化(接收方)
const received = FixedTransformState.fromRaw(message.state);
// 用于渲染
const { x, y, rotation } = received.toFloat();
sprite.position.set(x, y);
```
带速度的状态(用于外推):
```typescript
const state = FixedTransformStateWithVelocity.from(
100, 200, // 位置
0, // 旋转
5, 3, // 速度
0.1 // 角速度
);
```
### 定点数插值器
```typescript
import {
createFixedTransformInterpolator,
createFixedHermiteTransformInterpolator
} from '@esengine/network';
import { Fixed32 } from '@esengine/ecs-framework-math';
// 线性插值器
const interpolator = createFixedTransformInterpolator();
const from = FixedTransformState.from(0, 0, 0);
const to = FixedTransformState.from(100, 50, Math.PI);
const t = Fixed32.from(0.5);
const result = interpolator.interpolate(from, to, t);
// Hermite 插值器(更平滑)
const hermite = createFixedHermiteTransformInterpolator(100);
```
### 定点数快照缓冲区
管理定点数状态历史,用于帧同步回放:
```typescript
import {
FixedSnapshotBuffer,
createFixedSnapshotBuffer
} from '@esengine/network';
// 创建缓冲区(最多 30 快照2 帧延迟)
const buffer = createFixedSnapshotBuffer<FixedTransformState>(30, 2);
// 添加快照
buffer.push({
frame: 100,
state: FixedTransformState.from(100, 200, 0)
});
// 获取插值快照
const result = buffer.getInterpolationSnapshots(103);
if (result) {
const { from, to, t } = result;
const interpolated = interpolator.interpolate(from.state, to.state, t);
}
// 获取最新/指定帧快照
const latest = buffer.getLatest();
const atFrame = buffer.getAtFrame(100);
// 回滚重播
const snapshotsToReplay = buffer.getSnapshotsAfter(98);
// 清理旧快照
buffer.removeSnapshotsBefore(95);
```
子帧插值:
```typescript
// 使用 Fixed32 帧时间(支持小数帧)
const frameTime = Fixed32.from(102.5);
const result = buffer.getInterpolationSnapshotsFixed(frameTime);
```
### API 导出
```typescript
import {
// 状态类
FixedTransformState,
FixedTransformStateWithVelocity,
type IFixedTransformStateRaw,
type IFixedTransformStateWithVelocityRaw,
// 插值器
FixedTransformInterpolator,
FixedHermiteTransformInterpolator,
createFixedTransformInterpolator,
createFixedHermiteTransformInterpolator,
// 快照缓冲区
FixedSnapshotBuffer,
createFixedSnapshotBuffer,
type IFixedStateSnapshot,
type IFixedInterpolationResult
} from '@esengine/network';
```

View File

@@ -236,3 +236,257 @@ nav.sidebar-content ul li a[aria-current="page"] {
font-size: 2rem;
}
}
/* ==================== Blueprint Node Visualization ==================== */
/* Matches the actual node-editor component styles */
/* Graph Container - 使用固定像素坐标 */
.bp-graph {
position: relative;
background: #141419;
background-image:
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 20px 20px;
border-radius: 8px;
margin: 1.5rem 0;
border: 1px solid #2a2a35;
overflow: auto;
cursor: grab;
}
.bp-graph:active {
cursor: grabbing;
}
/* Inner container for panning */
.bp-graph-content {
position: relative;
transform-origin: 0 0;
}
/* SVG Connections Layer */
.bp-connections {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
overflow: visible;
}
/* Node absolute positioning in graph */
.bp-graph > .bp-node {
position: absolute;
}
/* Connection paths */
.bp-conn {
fill: none;
stroke-width: 2px;
stroke-linecap: round;
}
.bp-conn.exec { stroke: #ffffff; stroke-width: 3px; }
.bp-conn.float { stroke: #7ecd32; }
.bp-conn.int { stroke: #1cc4c4; }
.bp-conn.bool { stroke: #8c0000; }
.bp-conn.string { stroke: #e060e0; }
.bp-conn.object { stroke: #00a0e0; }
.bp-conn.entity { stroke: #00a0e0; }
.bp-conn.component { stroke: #7030c0; }
.bp-conn.array { stroke: #7030c0; }
.bp-conn.any { stroke: #707070; }
/* ==================== Node Container ==================== */
.bp-node {
position: relative;
min-width: 140px;
background: rgba(12, 12, 16, 0.95);
border: 1px solid rgba(40, 40, 50, 0.8);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 11px;
flex-shrink: 0;
box-sizing: border-box;
}
/* ==================== Node Header ==================== */
/* 与 node-editor 完全一致 */
.bp-node-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
min-height: 28px;
border-radius: 5px 5px 0 0;
color: #fff;
font-size: 12px;
font-weight: 500;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
position: relative;
}
/* Header icon - diamond for events */
.bp-node-header-icon {
width: 8px;
height: 8px;
flex-shrink: 0;
}
.bp-node-header.event .bp-node-header-icon {
background: #fff;
transform: rotate(45deg);
border-radius: 1px;
box-shadow: 0 0 4px rgba(255, 255, 255, 0.5);
}
/* Header title */
.bp-node-header-title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Header exec pin (for event nodes) - at right edge */
/* 与 node-editor 完全一致 */
.bp-header-exec {
width: 12px;
height: 12px;
margin-left: auto;
flex-shrink: 0;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.bp-header-exec svg {
display: block;
}
/* Category Colors */
.bp-node-header.event {
background: linear-gradient(180deg, #b81c1c 0%, #6b1010 100%);
}
.bp-node-header.function {
background: linear-gradient(180deg, #1b6eb5 0%, #0d3d66 100%);
}
.bp-node-header.pure {
background: linear-gradient(180deg, #3d8b3d 0%, #1f5a1f 100%);
}
.bp-node-header.flow {
background: linear-gradient(180deg, #4a4a4a 0%, #2a2a2a 100%);
}
.bp-node-header.variable {
background: linear-gradient(180deg, #7b3d9b 0%, #4a1f66 100%);
}
.bp-node-header.debug {
background: linear-gradient(180deg, #6a6a6a 0%, #3a3a3a 100%);
}
.bp-node-header.time {
background: linear-gradient(180deg, #1cc4c4 0%, #0d7070 100%);
}
.bp-node-header.math {
background: linear-gradient(180deg, #7ecd32 0%, #4a7a1e 100%);
}
/* ==================== Node Body ==================== */
/* 与 node-editor 完全一致 */
.bp-node-body {
padding: 6px 0;
background: rgba(8, 8, 12, 0.95);
border-radius: 0 0 5px 5px;
}
/* ==================== Pin Row ==================== */
/* 与 node-editor 完全一致 */
.bp-pin-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 20px;
padding: 1px 0;
position: relative;
box-sizing: border-box;
}
.bp-pin-row.input {
flex-direction: row;
justify-content: flex-start;
padding-left: 10px;
padding-right: 16px;
}
.bp-pin-row.output {
flex-direction: row-reverse;
justify-content: flex-start;
padding-right: 10px;
padding-left: 16px;
}
/* ==================== Pin (SVG-based) ==================== */
.bp-pin {
width: 12px;
height: 12px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.bp-pin svg {
display: block;
}
/* Pin Label */
.bp-pin-label {
color: #b0b0b0;
font-size: 11px;
white-space: nowrap;
line-height: 1;
}
.bp-pin-row.output .bp-pin-label {
text-align: right;
}
/* Pin Value */
.bp-pin-value {
color: #7ecd32;
font-size: 10px;
margin-left: auto;
font-family: 'Consolas', 'Monaco', monospace;
}
.bp-pin-row.output .bp-pin-value {
margin-left: 0;
margin-right: auto;
}
/* ==================== Legend ==================== */
.bp-legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 10px;
background: rgba(20, 20, 25, 0.8);
border-radius: 6px;
margin: 1rem 0;
}
.bp-legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #a0a0a0;
}

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';

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