Compare commits

...

31 Commits

Author SHA1 Message Date
YHH
b6aef13d93 Merge pull request #449 from esengine/docs/update-cocos-node-editor-v1.2.0
docs: update Cocos Node Editor download link to v1.2.0
2026-01-06 18:37:52 +08:00
yhh
470abb8750 Merge master into docs/update-cocos-node-editor-v1.2.0 2026-01-06 18:36:28 +08:00
yhh
2f95758911 docs: update Cocos Node Editor download link to v1.2.0 2026-01-06 18:35:24 +08:00
YHH
2e9f36b656 Merge pull request #448 from esengine/changeset-release/master
chore: release packages
2026-01-06 18:27:33 +08:00
github-actions[bot]
c188a36f2b chore: release packages 2026-01-06 10:26:20 +00:00
YHH
50681553b5 Merge pull request #447 from esengine/feat/blueprint-schema-system
feat(blueprint): add Schema type system and @BlueprintArray decorator
2026-01-06 18:24:26 +08:00
yhh
4e66bd8e2b feat(blueprint): add Schema type system and @BlueprintArray decorator
- Add Schema fluent API for defining complex data types
- Add @BlueprintArray decorator for array properties with itemSchema
- Support primitive types: float, int, string, boolean, vector2, vector3
- Support composite types: object, array, enum, ref
- Add path-utils for nested property access
- Update documentation with examples and usage guide
2026-01-06 18:19:08 +08:00
YHH
7caa69a22e Merge pull request #445 from esengine/changeset-release/master
chore: release packages
2026-01-06 11:32:03 +08:00
YHH
5a5daf7565 Merge branch 'master' into changeset-release/master 2026-01-06 11:28:27 +08:00
YHH
3415737fcc Merge pull request #446 from esengine/fix/cocos-editor-install-docs
docs(blueprint): fix Cocos extension installation instructions
2026-01-06 11:27:18 +08:00
yhh
876312deb2 docs(blueprint): fix Cocos extension installation instructions
- Change from manual copy to zip import via Extension Manager
- Add first-launch dependency installation flow description
- Plugin auto-detects and prompts to install @esengine/blueprint
2026-01-06 11:25:43 +08:00
github-actions[bot]
a790fc9e92 chore: release packages 2026-01-06 03:11:07 +00:00
YHH
fa593a3c69 docs(blueprint): fix Cocos editor integration guide (#444)
* feat(math): add blueprint nodes for math library

- Add Vector2 blueprint nodes (Make, Break, arithmetic, Length, Normalize, Dot, Cross, Distance, Lerp, Rotate, FromAngle)
- Add Fixed32 blueprint nodes (conversions, arithmetic, math functions, comparison)
- Add FixedVector2 blueprint nodes (Make, Break, arithmetic, vector operations)
- Add Color blueprint nodes (Make, Break, conversions, color manipulation, constants)
- Add documentation with visual examples for all math blueprint nodes
- Update sidebar navigation to include math module

* fix(ci): adjust build order - blueprint before math

math package now depends on blueprint, so blueprint must be built first

* docs(blueprint): fix Cocos editor integration guide

- Remove redundant component/system implementations
- Users should use BlueprintComponent and BlueprintSystem from @esengine/blueprint
- Add BlueprintComponent properties and methods reference table
2026-01-06 11:09:18 +08:00
github-actions[bot]
e7d95dfdaf chore: release packages (#443)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-06 10:34:07 +08:00
YHH
bffe90b6a1 feat(math): add blueprint nodes for math library (#442)
* feat(math): add blueprint nodes for math library

- Add Vector2 blueprint nodes (Make, Break, arithmetic, Length, Normalize, Dot, Cross, Distance, Lerp, Rotate, FromAngle)
- Add Fixed32 blueprint nodes (conversions, arithmetic, math functions, comparison)
- Add FixedVector2 blueprint nodes (Make, Break, arithmetic, vector operations)
- Add Color blueprint nodes (Make, Break, conversions, color manipulation, constants)
- Add documentation with visual examples for all math blueprint nodes
- Update sidebar navigation to include math module

* fix(ci): adjust build order - blueprint before math

math package now depends on blueprint, so blueprint must be built first
2026-01-06 10:32:02 +08:00
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
127 changed files with 16247 additions and 7042 deletions

View File

@@ -63,9 +63,9 @@ jobs:
- name: Build framework packages
run: |
pnpm --filter @esengine/ecs-framework build
pnpm --filter @esengine/blueprint build
pnpm --filter @esengine/ecs-framework-math build
pnpm --filter @esengine/behavior-tree build
pnpm --filter @esengine/blueprint build
pnpm --filter @esengine/fsm build
pnpm --filter @esengine/timer build
pnpm --filter @esengine/spatial build

View File

@@ -45,9 +45,9 @@ jobs:
run: |
# Only build packages managed by Changesets (not in ignore list)
pnpm --filter "@esengine/ecs-framework" build
pnpm --filter "@esengine/blueprint" build
pnpm --filter "@esengine/ecs-framework-math" build
pnpm --filter "@esengine/behavior-tree" build
pnpm --filter "@esengine/blueprint" build
pnpm --filter "@esengine/fsm" build
pnpm --filter "@esengine/timer" build
pnpm --filter "@esengine/spatial" build

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' } },
@@ -239,6 +247,14 @@ export default defineConfig({
{ label: '实际示例', slug: 'modules/blueprint/examples', translations: { en: 'Examples' } },
],
},
{
label: '数学库',
translations: { en: 'Math' },
items: [
{ label: '概述', slug: 'modules/math', translations: { en: 'Overview' } },
{ label: '蓝图节点', slug: 'modules/math/blueprint-nodes', translations: { en: 'Blueprint Nodes' } },
],
},
{
label: '程序生成',
translations: { en: 'Procgen' },
@@ -270,7 +286,9 @@ export default defineConfig({
{ 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' } },

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,315 @@
---
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. Download Extension
Download the `cocos-node-editor.zip` extension package from the release page.
### 2. Import Extension
1. Open Cocos Creator
2. Go to **Extensions → Extension Manager**
3. Click the **Import Extension** button
4. Select the downloaded `cocos-node-editor.zip` file
5. Enable the extension after importing
## Opening the Blueprint Editor
Open the blueprint editor panel via menu **Panel → Node Editor**.
### First Launch - Install Dependencies
When opening the panel for the first time, the plugin will check if `@esengine/blueprint` is installed in your project. If not installed, it will display **"Missing required dependencies"** prompt. Click the **"Install Dependencies"** button to install automatically.
## 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
The `@esengine/blueprint` package provides complete ECS integration, including `BlueprintComponent` and `BlueprintSystem` ready to use.
### 1. Add Blueprint System to Scene
```typescript
import { BlueprintSystem } from '@esengine/blueprint';
// Add blueprint system during scene initialization
scene.addSystem(new BlueprintSystem());
```
### 2. Load Blueprint and Add to Entity
```typescript
import { resources, JsonAsset } from 'cc';
import { BlueprintComponent, validateBlueprintAsset, BlueprintAsset } 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;
}
```
### BlueprintComponent Properties
| Property | Type | Description |
|----------|------|-------------|
| `blueprintAsset` | `BlueprintAsset \| null` | Blueprint asset data |
| `blueprintPath` | `string` | Blueprint asset path (for serialization) |
| `autoStart` | `boolean` | Auto-start execution (default `true`) |
| `debug` | `boolean` | Enable debug mode |
### BlueprintComponent Methods
| Method | Description |
|--------|-------------|
| `start()` | Manually start blueprint execution |
| `stop()` | Stop blueprint execution |
| `cleanup()` | Cleanup blueprint resources |
## 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

@@ -3,6 +3,164 @@ title: "Custom Nodes"
description: "Creating custom blueprint nodes"
---
## Blueprint Decorators
Use decorators to quickly expose ECS components as blueprint nodes.
### @BlueprintComponent
Mark a component class as blueprint-enabled:
```typescript
import { BlueprintComponent, BlueprintProperty } from '@esengine/blueprint';
@BlueprintComponent({
title: 'Player Controller',
category: 'gameplay',
color: '#4a90d9',
description: 'Controls player movement and interaction'
})
class PlayerController extends Component {
@BlueprintProperty({ displayName: 'Move Speed' })
speed: number = 100;
@BlueprintProperty({ displayName: 'Jump Height' })
jumpHeight: number = 200;
}
```
### @BlueprintProperty
Expose component properties as node inputs:
```typescript
@BlueprintProperty({
displayName: 'Health',
description: 'Current health value',
isInput: true,
isOutput: true
})
health: number = 100;
```
### @BlueprintArray
For array type properties, supports editing complex object arrays:
```typescript
import { BlueprintArray, Schema } from '@esengine/blueprint';
interface Waypoint {
position: { x: number; y: number };
waitTime: number;
speed: number;
}
@BlueprintComponent({
title: 'Patrol Path',
category: 'ai'
})
class PatrolPath extends Component {
@BlueprintArray({
displayName: 'Waypoints',
description: 'Points along the patrol path',
itemSchema: Schema.object({
position: Schema.vector2({ defaultValue: { x: 0, y: 0 } }),
waitTime: Schema.float({ min: 0, max: 10, defaultValue: 1.0 }),
speed: Schema.float({ min: 0, max: 500, defaultValue: 100 })
}),
reorderable: true,
exposeElementPorts: true,
portNameTemplate: 'Waypoint {index1}'
})
waypoints: Waypoint[] = [];
}
```
## Schema Type System
Schema defines type information for complex data structures, enabling the editor to automatically generate corresponding UI.
### Primitive Types
```typescript
import { Schema } from '@esengine/blueprint';
// Number types
Schema.float({ min: 0, max: 100, defaultValue: 50, step: 0.1 })
Schema.int({ min: 0, max: 10, defaultValue: 5 })
// String
Schema.string({ defaultValue: 'Hello', multiline: false, placeholder: 'Enter text...' })
// Boolean
Schema.boolean({ defaultValue: true })
// Vectors
Schema.vector2({ defaultValue: { x: 0, y: 0 } })
Schema.vector3({ defaultValue: { x: 0, y: 0, z: 0 } })
```
### Composite Types
```typescript
// Object
Schema.object({
name: Schema.string({ defaultValue: '' }),
health: Schema.float({ min: 0, max: 100 }),
position: Schema.vector2()
})
// Array
Schema.array({
items: Schema.float(),
minItems: 0,
maxItems: 10
})
// Enum
Schema.enum({
options: ['idle', 'walk', 'run', 'jump'],
defaultValue: 'idle'
})
// Reference
Schema.ref({ refType: 'entity' })
Schema.ref({ refType: 'asset', assetType: 'texture' })
```
### Complete Example
```typescript
@BlueprintComponent({ title: 'Enemy Config', category: 'ai' })
class EnemyConfig extends Component {
@BlueprintArray({
displayName: 'Attack Patterns',
itemSchema: Schema.object({
name: Schema.string({ defaultValue: 'Basic Attack' }),
damage: Schema.float({ min: 0, max: 100, defaultValue: 10 }),
cooldown: Schema.float({ min: 0, max: 10, defaultValue: 1 }),
range: Schema.float({ min: 0, max: 500, defaultValue: 50 }),
animation: Schema.string({ defaultValue: 'attack_01' })
}),
reorderable: true
})
attackPatterns: AttackPattern[] = [];
@BlueprintProperty({
displayName: 'Patrol Area',
schema: Schema.object({
center: Schema.vector2(),
radius: Schema.float({ min: 0, defaultValue: 100 })
})
})
patrolArea: { center: { x: number; y: number }; radius: number } = {
center: { x: 0, y: 0 },
radius: 100
};
}
```
## Defining Node Template
```typescript
@@ -33,16 +191,11 @@ import { INodeExecutor, RegisterNode, BlueprintNode, ExecutionContext, Execution
@RegisterNode(MyNodeTemplate)
class MyNodeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
// Get input (using evaluateInput)
const value = context.evaluateInput(node.id, 'value', 0) as number;
// Execute logic
const result = value * 2;
// Return result
return {
outputs: { result },
nextExec: 'exec' // Continue execution
nextExec: 'exec'
};
}
}
@@ -64,19 +217,10 @@ NodeRegistry.instance.register(MyNodeTemplate, new MyNodeExecutor());
```typescript
import { NodeRegistry } from '@esengine/blueprint';
// Get singleton
const registry = NodeRegistry.instance;
// Get all templates
const allTemplates = registry.getAllTemplates();
// Get by category
const mathNodes = registry.getTemplatesByCategory('math');
// Search nodes
const results = registry.searchTemplates('add');
// Check existence
if (registry.has('MyCustomNode')) { ... }
```
@@ -89,7 +233,7 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
type: 'GetDistance',
title: 'Get Distance',
category: 'math',
isPure: true, // Mark as pure node
isPure: true,
inputs: [
{ name: 'a', type: 'vector2', direction: 'input' },
{ name: 'b', type: 'vector2', direction: 'input' }
@@ -99,59 +243,3 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
]
};
```
## Example: ECS Component Operation Node
```typescript
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 }
],
outputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'newHealth', type: 'float', displayName: 'New Health' }
]
};
@RegisterNode(HealEntityTemplate)
class HealEntityExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
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.2.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.2.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

@@ -5,7 +5,17 @@ description: "Visual scripting system deeply integrated with ECS framework"
`@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.2.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.2.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
@@ -144,6 +154,7 @@ interface BlueprintAsset {
## 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

View File

@@ -1,118 +1,616 @@
---
title: "ECS Node Reference"
description: "Blueprint built-in ECS operation nodes"
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
Lifecycle events as blueprint entry points:
| Node | Description |
|------|-------------|
| `EventBeginPlay` | Triggered when blueprint starts |
| `EventTick` | Triggered each frame, receives deltaTime |
| `EventEndPlay` | Triggered when blueprint stops |
| Node | Description | Outputs |
|------|-------------|---------|
| `EventBeginPlay` | Triggered when blueprint starts | Exec, Self (Entity) |
| `EventTick` | Triggered each frame | Exec, Delta Time |
| `EventEndPlay` | Triggered when blueprint stops | Exec |
### Example: Game Initialization
<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>
### Example: Per-Frame Movement
<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
ECS entity operations:
Manipulate ECS entities:
| Node | Description | Type |
|------|-------------|------|
| `Get Self` | Get entity owning this blueprint | Pure |
| `Create Entity` | Create new entity in scene | Execution |
| `Destroy Entity` | Destroy specified entity | Execution |
| `Destroy Self` | Destroy self entity | Execution |
| `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 | Execution |
| `Get Entity Tag` | Get entity tag | Pure |
| `Set Entity Tag` | Set entity tag | Execution |
| `Set Active` | Set entity active state | Execution |
| `Is Active` | Check if entity is active | Pure |
| `Set Entity Name` | Set entity name | Exec |
| `Find Entity By Name` | Find entity by name | Pure |
| `Find Entities By Tag` | Find all entities by tag | Pure |
| `Get Entity ID` | Get entity unique ID | Pure |
| `Find Entity By ID` | Find entity by ID | Pure |
| `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
ECS component operations:
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 |
| `Get Component` | Get component from entity | Pure |
| `Get All Components` | Get all components from entity | Pure |
| `Remove Component` | Remove component | Execution |
| `Get Component Property` | Get component property value | Pure |
| `Set Component Property` | Set component property value | Execution |
| `Get Component Type` | Get component type name | Pure |
| `Get Owner Entity` | Get owning entity from component | Pure |
| `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 execution flow:
Control blueprint execution flow:
| Node | Description |
|------|-------------|
| `Branch` | Conditional branch (if/else) |
| `Sequence` | Execute multiple outputs in sequence |
| `For Loop` | Loop execution |
| `For Each` | Iterate array |
| `While Loop` | Conditional loop |
| `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 two branches |
| `Gate` | Toggleable execution gate |
| `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 | Type |
|------|-------------|------|
| `Delay` | Delay execution | Execution |
| `Get Delta Time` | Get frame delta time | Pure |
| `Get Time` | Get total runtime | Pure |
| 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 |
|------|-------------|
| `Add` / `Subtract` / `Multiply` / `Divide` | Basic operations |
| `Abs` | Absolute value |
| `Clamp` | Clamp to range |
| `Lerp` | Linear interpolation |
| `Min` / `Max` | Minimum/Maximum |
| `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 |
### Extended Math Nodes
> **Vector2, Fixed32, FixedVector2, Color** and other advanced math nodes are provided by the `@esengine/ecs-framework-math` module.
>
> See: [Math Blueprint Nodes](/en/modules/math/blueprint-nodes)
### 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
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 |
| `Print` | Output message to console |
## Auto-generated Component Nodes
## Related Documentation
Components marked with `@BlueprintExpose` decorator auto-generate nodes:
```typescript
@ECSComponent('Transform')
@BlueprintExpose({ displayName: 'Transform', category: 'core' })
export class TransformComponent extends Component {
@BlueprintProperty({ displayName: 'X Position' })
x: number = 0;
@BlueprintProperty({ displayName: 'Y Position' })
y: number = 0;
@BlueprintMethod({ displayName: 'Translate' })
translate(dx: number, dy: number): void {
this.x += dx;
this.y += dy;
}
}
```
Generated nodes:
- **Get Transform** - Get Transform component
- **Get X Position** / **Set X Position** - Access x property
- **Get Y Position** / **Set Y Position** - Access y property
- **Translate** - Call translate method
- [Math Blueprint Nodes](/en/modules/math/blueprint-nodes) - Vector2, Fixed32, Color and other math nodes
- [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

@@ -0,0 +1,489 @@
---
title: "Math Blueprint Nodes"
description: "Blueprint nodes provided by the Math module - Vector2, Fixed32, FixedVector2, Color"
---
This document describes the blueprint nodes provided by the `@esengine/ecs-framework-math` module.
> **Note**: These nodes require the math module to be installed.
<script src="/js/blueprint-graph.js"></script>
## Pin Type Legend
<div class="bp-legend">
<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="#2196F3" stroke-width="2"/></svg> Vector2</div>
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#9C27B0" stroke-width="2"/></svg> Fixed32</div>
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#673AB7" stroke-width="2"/></svg> FixedVector2</div>
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#FF9800" stroke-width="2"/></svg> Color</div>
</div>
---
## Vector2 Nodes
2D vector operations for position, velocity, and direction calculations.
### Node List
| Node | Description | Inputs | Output |
|------|-------------|--------|--------|
| `Make Vector2` | Create Vector2 from X, Y | X, Y | Vector2 |
| `Break Vector2` | Decompose Vector2 to X, Y | Vector | X, Y |
| `Vector2 +` | Vector addition | A, B | Vector2 |
| `Vector2 -` | Vector subtraction | A, B | Vector2 |
| `Vector2 *` | Vector scaling | Vector, Scalar | Vector2 |
| `Vector2 Length` | Get vector length | Vector | Float |
| `Vector2 Normalize` | Normalize to unit vector | Vector | Vector2 |
| `Vector2 Dot` | Dot product | A, B | Float |
| `Vector2 Cross` | 2D cross product | A, B | Float |
| `Vector2 Distance` | Distance between two points | A, B | Float |
| `Vector2 Lerp` | Linear interpolation | A, B, T | Vector2 |
| `Vector2 Rotate` | Rotate by angle (radians) | Vector, Angle | Vector2 |
| `Vector2 From Angle` | Create unit vector from angle | Angle | Vector2 |
### Example: Calculate Movement Direction
Direction vector from start to end point:
<div class="bp-graph" data-connections='[{"from":"v2-start","to":"v2-sub-a","type":"vector2"},{"from":"v2-end","to":"v2-sub-b","type":"vector2"},{"from":"v2-sub-result","to":"v2-norm-in","type":"vector2"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="position: absolute; left: 20px; top: 10px; width: 130px;">
<div class="bp-node-header math" style="background: #2196F3;">Make Vector2</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">X</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">Y</span>
<span class="bp-pin-value">0</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="v2-start"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">Vector</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 20px; top: 180px; width: 130px;">
<div class="bp-node-header math" style="background: #2196F3;">Make Vector2</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">X</span>
<span class="bp-pin-value">100</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">Y</span>
<span class="bp-pin-value">50</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="v2-end"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">Vector</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 220px; top: 90px; width: 120px;">
<div class="bp-node-header math" style="background: #2196F3;">Vector2 -</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="v2-sub-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">A</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="v2-sub-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">B</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="v2-sub-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 400px; top: 55px; width: 140px;">
<div class="bp-node-header math" style="background: #2196F3;">Vector2 Normalize</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="v2-norm-in"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">Vector</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="#2196F3"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
</div>
### Example: Circular Motion
Calculate circular position using angle and radius:
<div class="bp-graph" data-connections='[{"from":"v2-angle-out","to":"v2-scale-vec","type":"vector2"},{"from":"v2-scale-result","to":"v2-add-b","type":"vector2"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="position: absolute; left: 20px; top: 40px; width: 150px;">
<div class="bp-node-header math" style="background: #2196F3;">Vector2 From Angle</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">Angle</span>
<span class="bp-pin-value">1.57</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="v2-angle-out"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">Vector</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 230px; top: 40px; width: 120px;">
<div class="bp-node-header math" style="background: #2196F3;">Vector2 *</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="v2-scale-vec"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">Vector</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">Scalar</span>
<span class="bp-pin-value">50</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="v2-scale-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 420px; top: 40px; width: 120px;">
<div class="bp-node-header math" style="background: #2196F3;">Vector2 +</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="#2196F3" stroke-width="2"/></svg></span>
<span class="bp-pin-label">A (Center)</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="v2-add-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">B</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="#2196F3"/></svg></span>
<span class="bp-pin-label">Position</span>
</div>
</div>
</div>
</div>
---
## Fixed32 Nodes (Fixed-Point Numbers)
Q16.16 fixed-point number operations for lockstep networking games, ensuring cross-platform calculation consistency.
### Node List
| Node | Description | Inputs | Output |
|------|-------------|--------|--------|
| `Fixed32 From Float` | Create from float | Float | Fixed32 |
| `Fixed32 From Int` | Create from integer | Int | Fixed32 |
| `Fixed32 To Float` | Convert to float | Fixed32 | Float |
| `Fixed32 To Int` | Convert to integer | Fixed32 | Int |
| `Fixed32 +` | Addition | A, B | Fixed32 |
| `Fixed32 -` | Subtraction | A, B | Fixed32 |
| `Fixed32 *` | Multiplication | A, B | Fixed32 |
| `Fixed32 /` | Division | A, B | Fixed32 |
| `Fixed32 Abs` | Absolute value | Value | Fixed32 |
| `Fixed32 Sqrt` | Square root | Value | Fixed32 |
| `Fixed32 Floor` | Floor | Value | Fixed32 |
| `Fixed32 Ceil` | Ceiling | Value | Fixed32 |
| `Fixed32 Round` | Round | Value | Fixed32 |
| `Fixed32 Sign` | Sign (-1, 0, 1) | Value | Fixed32 |
| `Fixed32 Min` | Minimum | A, B | Fixed32 |
| `Fixed32 Max` | Maximum | A, B | Fixed32 |
| `Fixed32 Clamp` | Clamp to range | Value, Min, Max | Fixed32 |
| `Fixed32 Lerp` | Linear interpolation | A, B, T | Fixed32 |
### Example: Lockstep Movement Speed Calculation
<div class="bp-graph" data-connections='[{"from":"f32-speed","to":"f32-mul-a","type":"fixed32"},{"from":"f32-dt","to":"f32-mul-b","type":"fixed32"},{"from":"f32-mul-result","to":"f32-tofloat","type":"fixed32"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="position: absolute; left: 20px; top: 10px; width: 150px;">
<div class="bp-node-header math" style="background: #9C27B0;">Fixed32 From Float</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">Value</span>
<span class="bp-pin-value">5.0</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="f32-speed"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
<span class="bp-pin-label">Speed</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 20px; top: 160px; width: 150px;">
<div class="bp-node-header math" style="background: #9C27B0;">Fixed32 From Float</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">Value</span>
<span class="bp-pin-value">0.016</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="f32-dt"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
<span class="bp-pin-label">DeltaTime</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 240px; top: 75px; width: 120px;">
<div class="bp-node-header math" style="background: #9C27B0;">Fixed32 *</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="f32-mul-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
<span class="bp-pin-label">A</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="f32-mul-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
<span class="bp-pin-label">B</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="f32-mul-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 430px; top: 75px; width: 150px;">
<div class="bp-node-header math" style="background: #9C27B0;">Fixed32 To Float</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="f32-tofloat"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
<span class="bp-pin-label">Fixed</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">Float</span>
</div>
</div>
</div>
</div>
---
## FixedVector2 Nodes (Fixed-Point Vectors)
Fixed-point vector operations for deterministic physics calculations, suitable for lockstep networking.
### Node List
| Node | Description | Inputs | Output |
|------|-------------|--------|--------|
| `Make FixedVector2` | Create from X, Y floats | X, Y | FixedVector2 |
| `Break FixedVector2` | Decompose to X, Y floats | Vector | X, Y |
| `FixedVector2 +` | Vector addition | A, B | FixedVector2 |
| `FixedVector2 -` | Vector subtraction | A, B | FixedVector2 |
| `FixedVector2 *` | Scale by Fixed32 | Vector, Scalar | FixedVector2 |
| `FixedVector2 Negate` | Negate vector | Vector | FixedVector2 |
| `FixedVector2 Length` | Get length | Vector | Fixed32 |
| `FixedVector2 Normalize` | Normalize | Vector | FixedVector2 |
| `FixedVector2 Dot` | Dot product | A, B | Fixed32 |
| `FixedVector2 Cross` | 2D cross product | A, B | Fixed32 |
| `FixedVector2 Distance` | Distance between points | A, B | Fixed32 |
| `FixedVector2 Lerp` | Linear interpolation | A, B, T | FixedVector2 |
### Example: Deterministic Position Update
<div class="bp-graph" data-connections='[{"from":"fv2-pos","to":"fv2-add-a","type":"fixedvector2"},{"from":"fv2-vel","to":"fv2-add-b","type":"fixedvector2"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="position: absolute; left: 20px; top: 10px; width: 150px;">
<div class="bp-node-header math" style="background: #673AB7;">Make FixedVector2</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">X</span>
<span class="bp-pin-value">10</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">Y</span>
<span class="bp-pin-value">20</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="fv2-pos"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
<span class="bp-pin-label">Position</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 20px; top: 180px; width: 150px;">
<div class="bp-node-header math" style="background: #673AB7;">Make FixedVector2</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">X</span>
<span class="bp-pin-value">1</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">Y</span>
<span class="bp-pin-value">0</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="fv2-vel"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
<span class="bp-pin-label">Velocity</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 250px; top: 90px; width: 140px;">
<div class="bp-node-header math" style="background: #673AB7;">FixedVector2 +</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="fv2-add-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
<span class="bp-pin-label">A</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="fv2-add-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
<span class="bp-pin-label">B</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="#673AB7"/></svg></span>
<span class="bp-pin-label">New Position</span>
</div>
</div>
</div>
</div>
---
## Color Nodes
Color creation and manipulation nodes.
### Node List
| Node | Description | Inputs | Output |
|------|-------------|--------|--------|
| `Make Color` | Create from RGBA | R, G, B, A | Color |
| `Break Color` | Decompose to RGBA | Color | R, G, B, A |
| `Color From Hex` | Create from hex string | Hex | Color |
| `Color To Hex` | Convert to hex string | Color | String |
| `Color From HSL` | Create from HSL | H, S, L | Color |
| `Color To HSL` | Convert to HSL | Color | H, S, L |
| `Color Lerp` | Color interpolation | A, B, T | Color |
| `Color Lighten` | Lighten | Color, Amount | Color |
| `Color Darken` | Darken | Color, Amount | Color |
| `Color Saturate` | Increase saturation | Color, Amount | Color |
| `Color Desaturate` | Decrease saturation | Color, Amount | Color |
| `Color Invert` | Invert | Color | Color |
| `Color Grayscale` | Convert to grayscale | Color | Color |
| `Color Luminance` | Get luminance | Color | Float |
### Color Constants
| Node | Value |
|------|-------|
| `Color White` | (1, 1, 1, 1) |
| `Color Black` | (0, 0, 0, 1) |
| `Color Red` | (1, 0, 0, 1) |
| `Color Green` | (0, 1, 0, 1) |
| `Color Blue` | (0, 0, 1, 1) |
| `Color Transparent` | (0, 0, 0, 0) |
### Example: Color Transition Animation
<div class="bp-graph" data-connections='[{"from":"color-a","to":"color-lerp-a","type":"color"},{"from":"color-b","to":"color-lerp-b","type":"color"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="position: absolute; left: 20px; top: 10px; width: 120px;">
<div class="bp-node-header math" style="background: #FF9800;">Color Red</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="color-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
<span class="bp-pin-label">Color</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 20px; top: 130px; width: 120px;">
<div class="bp-node-header math" style="background: #FF9800;">Color Blue</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="color-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
<span class="bp-pin-label">Color</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 220px; top: 50px; width: 130px;">
<div class="bp-node-header math" style="background: #FF9800;">Color Lerp</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="color-lerp-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
<span class="bp-pin-label">A</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="color-lerp-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
<span class="bp-pin-label">B</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">T</span>
<span class="bp-pin-value">0.5</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="#FF9800"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
</div>
### Example: Create Color from Hex
<div class="bp-graph" data-connections='[{"from":"hex-color","to":"break-color","type":"color"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="position: absolute; left: 20px; top: 30px; width: 150px;">
<div class="bp-node-header math" style="background: #FF9800;">Color From Hex</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="#e060e0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Hex</span>
<span class="bp-pin-value">"#FF5722"</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="hex-color"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
<span class="bp-pin-label">Color</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 250px; top: 20px; width: 130px;">
<div class="bp-node-header math" style="background: #FF9800;">Break Color</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="break-color"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
<span class="bp-pin-label">Color</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">R</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">G</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">B</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">A</span>
</div>
</div>
</div>
</div>
---
## Related Documentation
- [Blueprint Node Reference](/en/modules/blueprint/nodes) - Core blueprint nodes
- [Blueprint Editor Guide](/en/modules/blueprint/editor-guide) - Editor usage
- [Custom Nodes](/en/modules/blueprint/custom-nodes) - Create custom nodes

View File

@@ -0,0 +1,79 @@
---
title: "Math Library"
description: "ESEngine Math Library - Vector2, Fixed32, FixedVector2, Color and other math types"
---
The `@esengine/ecs-framework-math` module provides common math types and operations for game development.
## Core Types
| Type | Description |
|------|-------------|
| `Vector2` | 2D floating-point vector for position, velocity, direction |
| `Fixed32` | Q16.16 fixed-point number for deterministic lockstep calculations |
| `FixedVector2` | 2D fixed-point vector for deterministic physics |
| `Color` | RGBA color |
## Features
### Vector2
- Addition, subtraction, scaling
- Dot product, cross product
- Length, normalization
- Distance, interpolation
- Rotation, angle conversion
### Fixed32 Fixed-Point Numbers
Designed for lockstep networking games, ensuring cross-platform calculation consistency:
- Basic operations: add, subtract, multiply, divide
- Math functions: absolute value, square root, rounding
- Comparison, clamping, interpolation
- Constants: 0, 1, 0.5, PI, 2*PI
### Color
- RGB/RGBA creation and decomposition
- Hex string conversion
- HSL color space conversion
- Color operations: lighten, darken, saturation adjustment
- Color blending and interpolation
## Blueprint Support
The math library provides rich blueprint nodes, see:
- [Math Blueprint Nodes](/en/modules/math/blueprint-nodes)
## Installation
```bash
pnpm add @esengine/ecs-framework-math
```
## Basic Usage
```typescript
import { Vector2, Fixed32, FixedVector2, Color } from '@esengine/ecs-framework-math';
// Vector2
const pos = new Vector2(10, 20);
const dir = pos.normalized();
// Fixed32 (lockstep)
const speed = Fixed32.from(5.0);
const dt = Fixed32.from(0.016);
const distance = speed.mul(dt);
// FixedVector2
const fixedPos = FixedVector2.from(10, 20);
const fixedVel = FixedVector2.from(1, 0);
const newPos = fixedPos.add(fixedVel);
// Color
const red = Color.RED;
const blue = Color.BLUE;
const purple = Color.lerp(red, blue, 0.5);
```

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

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

@@ -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,315 @@
---
title: "Cocos Creator 蓝图编辑器"
description: "在 Cocos Creator 中使用蓝图可视化脚本系统"
---
本文档介绍如何在 Cocos Creator 项目中安装和使用蓝图可视化脚本编辑器扩展。
## 安装扩展
### 1. 下载扩展
从发布页面下载 `cocos-node-editor.zip` 扩展包。
### 2. 导入扩展
1. 打开 Cocos Creator
2. 进入 **扩展 → 扩展管理器**
3. 点击 **导入扩展包** 按钮
4. 选择下载的 `cocos-node-editor.zip` 文件
5. 导入后启用扩展
## 打开蓝图编辑器
通过菜单 **面板 → Node Editor** 打开蓝图编辑器面板。
### 首次打开 - 安装依赖
首次打开面板时,插件会检测项目中是否安装了 `@esengine/blueprint` 依赖包。如果未安装,会显示 **"缺少必要的依赖包"** 提示,点击 **"安装依赖"** 按钮即可自动安装。
## 编辑器界面
### 工具栏
| 按钮 | 快捷键 | 功能 |
|------|--------|------|
| 新建 | - | 创建空白蓝图 |
| 加载 | - | 从文件加载蓝图 |
| 保存 | `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"
}
]
}
```
## 在游戏中运行蓝图
`@esengine/blueprint` 包已提供完整的 ECS 集成,包括 `BlueprintComponent``BlueprintSystem`,可以直接使用。
### 1. 添加蓝图系统到场景
```typescript
import { BlueprintSystem } from '@esengine/blueprint';
// 在场景初始化时添加蓝图系统
scene.addSystem(new BlueprintSystem());
```
### 2. 加载蓝图并添加到实体
```typescript
import { resources, JsonAsset } from 'cc';
import { BlueprintComponent, validateBlueprintAsset, BlueprintAsset } 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;
}
```
### BlueprintComponent 属性
| 属性 | 类型 | 说明 |
|------|------|------|
| `blueprintAsset` | `BlueprintAsset \| null` | 蓝图资产数据 |
| `blueprintPath` | `string` | 蓝图资产路径(用于序列化) |
| `autoStart` | `boolean` | 是否自动开始执行(默认 `true` |
| `debug` | `boolean` | 是否启用调试模式 |
### BlueprintComponent 方法
| 方法 | 说明 |
|------|------|
| `start()` | 手动开始执行蓝图 |
| `stop()` | 停止蓝图执行 |
| `cleanup()` | 清理蓝图资源 |
## 创建自定义节点
### 使用装饰器标记组件
推荐使用装饰器让组件自动生成蓝图节点:
```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

@@ -3,6 +3,164 @@ title: "自定义节点"
description: "创建自定义蓝图节点"
---
## 蓝图装饰器
使用装饰器可以快速将 ECS 组件暴露为蓝图节点。
### @BlueprintComponent
将组件类标记为蓝图可用:
```typescript
import { BlueprintComponent, BlueprintProperty } from '@esengine/blueprint';
@BlueprintComponent({
title: '玩家控制器',
category: 'gameplay',
color: '#4a90d9',
description: '控制玩家移动和交互'
})
class PlayerController extends Component {
@BlueprintProperty({ displayName: '移动速度' })
speed: number = 100;
@BlueprintProperty({ displayName: '跳跃高度' })
jumpHeight: number = 200;
}
```
### @BlueprintProperty
将组件属性暴露为节点输入:
```typescript
@BlueprintProperty({
displayName: '生命值',
description: '当前生命值',
isInput: true,
isOutput: true
})
health: number = 100;
```
### @BlueprintArray
用于数组类型属性,支持复杂对象数组的编辑:
```typescript
import { BlueprintArray, Schema } from '@esengine/blueprint';
interface Waypoint {
position: { x: number; y: number };
waitTime: number;
speed: number;
}
@BlueprintComponent({
title: '巡逻路径',
category: 'ai'
})
class PatrolPath extends Component {
@BlueprintArray({
displayName: '路径点',
description: '巡逻路径的各个点',
itemSchema: Schema.object({
position: Schema.vector2({ defaultValue: { x: 0, y: 0 } }),
waitTime: Schema.float({ min: 0, max: 10, defaultValue: 1.0 }),
speed: Schema.float({ min: 0, max: 500, defaultValue: 100 })
}),
reorderable: true,
exposeElementPorts: true,
portNameTemplate: '路径点 {index1}'
})
waypoints: Waypoint[] = [];
}
```
## Schema 类型系统
Schema 用于定义复杂数据结构的类型信息,支持编辑器自动生成对应的 UI。
### 基础类型
```typescript
import { Schema } from '@esengine/blueprint';
// 数字类型
Schema.float({ min: 0, max: 100, defaultValue: 50, step: 0.1 })
Schema.int({ min: 0, max: 10, defaultValue: 5 })
// 字符串
Schema.string({ defaultValue: 'Hello', multiline: false, placeholder: '输入文本...' })
// 布尔
Schema.boolean({ defaultValue: true })
// 向量
Schema.vector2({ defaultValue: { x: 0, y: 0 } })
Schema.vector3({ defaultValue: { x: 0, y: 0, z: 0 } })
```
### 复合类型
```typescript
// 对象
Schema.object({
name: Schema.string({ defaultValue: '' }),
health: Schema.float({ min: 0, max: 100 }),
position: Schema.vector2()
})
// 数组
Schema.array({
items: Schema.float(),
minItems: 0,
maxItems: 10
})
// 枚举
Schema.enum({
options: ['idle', 'walk', 'run', 'jump'],
defaultValue: 'idle'
})
// 引用
Schema.ref({ refType: 'entity' })
Schema.ref({ refType: 'asset', assetType: 'texture' })
```
### 完整示例
```typescript
@BlueprintComponent({ title: '敌人配置', category: 'ai' })
class EnemyConfig extends Component {
@BlueprintArray({
displayName: '攻击模式',
itemSchema: Schema.object({
name: Schema.string({ defaultValue: '普通攻击' }),
damage: Schema.float({ min: 0, max: 100, defaultValue: 10 }),
cooldown: Schema.float({ min: 0, max: 10, defaultValue: 1 }),
range: Schema.float({ min: 0, max: 500, defaultValue: 50 }),
animation: Schema.string({ defaultValue: 'attack_01' })
}),
reorderable: true
})
attackPatterns: AttackPattern[] = [];
@BlueprintProperty({
displayName: '巡逻区域',
schema: Schema.object({
center: Schema.vector2(),
radius: Schema.float({ min: 0, defaultValue: 100 })
})
})
patrolArea: { center: { x: number; y: number }; radius: number } = {
center: { x: 0, y: 0 },
radius: 100
};
}
```
## 定义节点模板
```typescript
@@ -33,16 +191,11 @@ import { INodeExecutor, RegisterNode, BlueprintNode, ExecutionContext, Execution
@RegisterNode(MyNodeTemplate)
class MyNodeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
// 获取输入(使用 evaluateInput
const value = context.evaluateInput(node.id, 'value', 0) as number;
// 执行逻辑
const result = value * 2;
// 返回结果
return {
outputs: { result },
nextExec: 'exec' // 继续执行
nextExec: 'exec'
};
}
}
@@ -64,19 +217,10 @@ NodeRegistry.instance.register(MyNodeTemplate, new MyNodeExecutor());
```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')) { ... }
```
@@ -89,7 +233,7 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
type: 'GetDistance',
title: 'Get Distance',
category: 'math',
isPure: true, // 标记为纯节点
isPure: true,
inputs: [
{ name: 'a', type: 'vector2', direction: 'input' },
{ name: 'b', type: 'vector2', direction: 'input' }
@@ -99,59 +243,3 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
]
};
```
## 实际示例ECS 组件操作节点
```typescript
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 }
],
outputs: [
{ name: 'exec', type: 'exec', displayName: '' },
{ name: 'newHealth', type: 'float', displayName: 'New Health' }
]
};
@RegisterNode(HealEntityTemplate)
class HealEntityExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
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.2.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.2.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

@@ -5,7 +5,17 @@ description: "与 ECS 框架深度集成的可视化脚本系统"
`@esengine/blueprint` 提供与 ECS 框架深度集成的可视化脚本系统,支持通过节点式编程控制实体行为。
## 安装
## 编辑器下载
Cocos Creator 蓝图编辑器插件(免费):
**[下载 Cocos Node Editor v1.2.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.2.0)**
> 技术交流 QQ 群:**481923584** | 官网:[esengine.cn](https://esengine.cn/)
详细使用教程请参考 [编辑器使用指南](./editor-guide)。
## 安装运行时
```bash
npm install @esengine/blueprint
@@ -144,6 +154,7 @@ interface BlueprintAsset {
## 文档导航
- [编辑器使用指南](./editor-guide) - Cocos Creator 蓝图编辑器教程
- [虚拟机 API](./vm) - BlueprintVM 与 ECS 集成
- [ECS 节点参考](./nodes) - 内置 ECS 操作节点
- [自定义节点](./custom-nodes) - 创建自定义 ECS 节点

View File

@@ -1,17 +1,117 @@
---
title: "ECS 节点参考"
description: "蓝图内置 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` | 每帧触发,接收 deltaTime |
| `EventEndPlay` | 蓝图停止时触发 |
| 节点 | 说明 | 输出 |
|------|------|------|
| `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)
@@ -26,93 +126,422 @@ description: "蓝图内置 ECS 操作节点"
| `Is Valid` | 检查实体是否有效 | 纯节点 |
| `Get Entity Name` | 获取实体名称 | 纯节点 |
| `Set Entity Name` | 设置实体名称 | 执行节点 |
| `Get Entity Tag` | 获取实体标签 | 纯节点 |
| `Set Entity Tag` | 设置实体标签 | 执行节点 |
| `Set Active` | 设置实体激活状态 | 执行节点 |
| `Is Active` | 检查实体是否激活 | 纯节点 |
| `Find Entity By Name` | 按名称查找实体 | 纯节点 |
| `Find Entities By Tag` | 按标签查找所有实体 | 纯节点 |
| `Get Entity ID` | 获取实体唯一 ID | 纯节点 |
| `Find Entity By ID` | 按 ID 查找实体 | 纯节点 |
### 示例:创建子弹
<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)
操作 ECS 组件
读写组件属性
| 节点 | 说明 | 类型 |
|------|------|------|
| `Has Component` | 检查实体是否有指定组件 | 纯节点 |
| `Get Component` | 获取实体的组件 | 纯节点 |
| `Get All Components` | 获取实体所有组件 | 节点 |
| `Remove Component` | 移除组件 | 执行节点 |
| `Get Component Property` | 获取组件属性值 | 纯节点 |
| `Set Component Property` | 设置组件属性值 | 执行节点 |
| `Get Component Type` | 获取组件类型名称 | 纯节点 |
| `Get Owner Entity` | 从组件获取所属实体 | 纯节点 |
| `Get Component` | 从实体获取指定类型组件 | 纯节点 |
| `Has Component` | 检查实体是否拥有指定组件 | 纯节点 |
| `Add Component` | 为实体添加组件 | 执行节点 |
| `Remove Component` | 从实体移除组件 | 执行节点 |
| `Get Property` | 获取组件属性值 | 纯节点 |
| `Set Property` | 设置组件属性值 | 执行节点 |
## 流程控制节点 (Flow)
### 示例:修改位置
控制执行流程:
<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` | 顺序执行多个输出 |
| `For Loop` | 循环执行 |
| `For Each` | 遍历数组 |
| `Branch` | 条件分支if/else |
| `Sequence` | 顺序执行多个分支 |
| `For Loop` | 指定次数循环 |
| `For Each` | 遍历数组元素 |
| `While Loop` | 条件循环 |
| `Do Once` | 执行一次 |
| `Flip Flop` | 交替执行两个分支 |
| `Gate` | 可开关的执行门 |
| `Do Once` | 执行一次 |
| `Flip Flop` | 交替执行 A/B |
| `Gate` | 门控开关 |
## 时间节点 (Time)
### 示例:条件分支
<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` | 延迟执行指定秒数 | 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` | 加法 | 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) |
### 三角函数
| 节点 | 说明 |
|------|------|
| `Sin` | 正弦 |
| `Cos` | 余弦 |
| `Tan` | 正切 |
| `Asin` | 反正弦 |
| `Acos` | 反余弦 |
| `Atan` | 反正切 |
| `Atan2` | 二参数反正切 |
### 随机数
| 节点 | 说明 | 输入 | 输出 |
|------|------|------|------|
| `Random` | 随机浮点数 [0, 1) | - | Float |
| `Random Range` | 范围内随机数 | Min, Max | Float |
| `Random Int` | 随机整数 | Min, Max | Int |
### 比较节点
| 节点 | 说明 | 输出 |
|------|------|------|
| `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 |
### 扩展数学节点
> **Vector2、Fixed32、FixedVector2、Color** 等高级数学节点由 `@esengine/ecs-framework-math` 模块提供。
>
> 详见:[数学库蓝图节点](/modules/math/blueprint-nodes)
### 示例:钳制数值
<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>
## 变量节点
蓝图定义的变量会自动生成 Get 和 Set 节点:
| 节点 | 说明 | 类型 |
|------|------|------|
| `Delay` | 延迟执行 | 执行节点 |
| `Get Delta Time` | 获取帧间隔时间 | 节点 |
| `Get Time` | 获取运行总时间 | 纯节点 |
| `Get <变量名>` | 读取变量值 | 节点 |
| `Set <变量名>` | 设置变量值 | 执行节点 |
## 数学节点 (Math)
## 调试节点
| 节点 | 说明 |
|------|------|
| `Add` / `Subtract` / `Multiply` / `Divide` | 四则运算 |
| `Abs` | 绝对值 |
| `Clamp` | 限制范围 |
| `Lerp` | 线性插值 |
| `Min` / `Max` | 最小/最大值 |
| `Print` | 输出消息到控制台 |
## 调试节点 (Debug)
## 相关文档
| 节点 | 说明 |
|------|------|
| `Print` | 输出到控制台 |
## 自动生成的组件节点
使用 `@BlueprintExpose` 装饰器标记的组件会自动生成节点:
```typescript
@ECSComponent('Transform')
@BlueprintExpose({ displayName: '变换', category: 'core' })
export class TransformComponent extends Component {
@BlueprintProperty({ displayName: 'X 坐标' })
x: number = 0;
@BlueprintProperty({ displayName: 'Y 坐标' })
y: number = 0;
@BlueprintMethod({ displayName: '移动' })
translate(dx: number, dy: number): void {
this.x += dx;
this.y += dy;
}
}
```
生成的节点:
- **Get Transform** - 获取 Transform 组件
- **Get X 坐标** / **Set X 坐标** - 访问 x 属性
- **Get Y 坐标** / **Set Y 坐标** - 访问 y 属性
- **移动** - 调用 translate 方法
- [数学库蓝图节点](/modules/math/blueprint-nodes) - Vector2、Fixed32、Color 等数学节点
- [蓝图编辑器指南](/modules/blueprint/editor-guide) - 学习如何使用编辑器
- [自定义节点](/modules/blueprint/custom-nodes) - 创建自定义节点
- [蓝图虚拟机](/modules/blueprint/vm) - 运行时 API

View File

@@ -0,0 +1,489 @@
---
title: "数学库蓝图节点"
description: "Math 模块提供的蓝图节点 - Vector2、Fixed32、FixedVector2、Color"
---
本文档介绍 `@esengine/ecs-framework-math` 模块提供的蓝图节点。
> **注意**:这些节点需要安装 math 模块才能使用。
<script src="/js/blueprint-graph.js"></script>
## 引脚类型说明
<div class="bp-legend">
<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="#2196F3" stroke-width="2"/></svg> Vector2</div>
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#9C27B0" stroke-width="2"/></svg> Fixed32</div>
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#673AB7" stroke-width="2"/></svg> FixedVector2</div>
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#FF9800" stroke-width="2"/></svg> Color</div>
</div>
---
## Vector2 节点
2D 向量操作,用于位置、速度、方向计算。
### 节点列表
| 节点 | 说明 | 输入 | 输出 |
|------|------|------|------|
| `Make Vector2` | 从 X, Y 创建 Vector2 | X, Y | Vector2 |
| `Break Vector2` | 分解 Vector2 为 X, Y | Vector | X, Y |
| `Vector2 +` | 向量加法 | A, B | Vector2 |
| `Vector2 -` | 向量减法 | A, B | Vector2 |
| `Vector2 *` | 向量缩放 | Vector, Scalar | Vector2 |
| `Vector2 Length` | 获取向量长度 | Vector | Float |
| `Vector2 Normalize` | 归一化为单位向量 | Vector | Vector2 |
| `Vector2 Dot` | 点积 | A, B | Float |
| `Vector2 Cross` | 2D 叉积 | A, B | Float |
| `Vector2 Distance` | 两点距离 | A, B | Float |
| `Vector2 Lerp` | 线性插值 | A, B, T | Vector2 |
| `Vector2 Rotate` | 旋转(弧度) | Vector, Angle | Vector2 |
| `Vector2 From Angle` | 从角度创建单位向量 | Angle | Vector2 |
### 示例:计算移动方向
从起点到终点的方向向量:
<div class="bp-graph" data-connections='[{"from":"v2-start","to":"v2-sub-a","type":"vector2"},{"from":"v2-end","to":"v2-sub-b","type":"vector2"},{"from":"v2-sub-result","to":"v2-norm-in","type":"vector2"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="position: absolute; left: 20px; top: 10px; width: 130px;">
<div class="bp-node-header math" style="background: #2196F3;">Make Vector2</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">X</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">Y</span>
<span class="bp-pin-value">0</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="v2-start"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">Vector</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 20px; top: 180px; width: 130px;">
<div class="bp-node-header math" style="background: #2196F3;">Make Vector2</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">X</span>
<span class="bp-pin-value">100</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">Y</span>
<span class="bp-pin-value">50</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="v2-end"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">Vector</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 220px; top: 90px; width: 120px;">
<div class="bp-node-header math" style="background: #2196F3;">Vector2 -</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="v2-sub-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">A</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="v2-sub-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">B</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="v2-sub-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 400px; top: 55px; width: 140px;">
<div class="bp-node-header math" style="background: #2196F3;">Vector2 Normalize</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="v2-norm-in"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">Vector</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="#2196F3"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
</div>
### 示例:圆周运动
使用角度和半径计算圆周位置:
<div class="bp-graph" data-connections='[{"from":"v2-angle-out","to":"v2-scale-vec","type":"vector2"},{"from":"v2-scale-result","to":"v2-add-b","type":"vector2"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="position: absolute; left: 20px; top: 40px; width: 150px;">
<div class="bp-node-header math" style="background: #2196F3;">Vector2 From Angle</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">Angle</span>
<span class="bp-pin-value">1.57</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="v2-angle-out"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">Vector</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 230px; top: 40px; width: 120px;">
<div class="bp-node-header math" style="background: #2196F3;">Vector2 *</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="v2-scale-vec"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">Vector</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">Scalar</span>
<span class="bp-pin-value">50</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="v2-scale-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 420px; top: 40px; width: 120px;">
<div class="bp-node-header math" style="background: #2196F3;">Vector2 +</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="#2196F3" stroke-width="2"/></svg></span>
<span class="bp-pin-label">A (Center)</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="v2-add-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#2196F3"/></svg></span>
<span class="bp-pin-label">B</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="#2196F3"/></svg></span>
<span class="bp-pin-label">Position</span>
</div>
</div>
</div>
</div>
---
## Fixed32 定点数节点
Q16.16 定点数运算,适用于帧同步网络游戏,保证跨平台计算一致性。
### 节点列表
| 节点 | 说明 | 输入 | 输出 |
|------|------|------|------|
| `Fixed32 From Float` | 从浮点数创建 | Float | Fixed32 |
| `Fixed32 From Int` | 从整数创建 | Int | Fixed32 |
| `Fixed32 To Float` | 转换为浮点数 | Fixed32 | Float |
| `Fixed32 To Int` | 转换为整数 | Fixed32 | Int |
| `Fixed32 +` | 加法 | A, B | Fixed32 |
| `Fixed32 -` | 减法 | A, B | Fixed32 |
| `Fixed32 *` | 乘法 | A, B | Fixed32 |
| `Fixed32 /` | 除法 | A, B | Fixed32 |
| `Fixed32 Abs` | 绝对值 | Value | Fixed32 |
| `Fixed32 Sqrt` | 平方根 | Value | Fixed32 |
| `Fixed32 Floor` | 向下取整 | Value | Fixed32 |
| `Fixed32 Ceil` | 向上取整 | Value | Fixed32 |
| `Fixed32 Round` | 四舍五入 | Value | Fixed32 |
| `Fixed32 Sign` | 符号 (-1, 0, 1) | Value | Fixed32 |
| `Fixed32 Min` | 最小值 | A, B | Fixed32 |
| `Fixed32 Max` | 最大值 | A, B | Fixed32 |
| `Fixed32 Clamp` | 钳制范围 | Value, Min, Max | Fixed32 |
| `Fixed32 Lerp` | 线性插值 | A, B, T | Fixed32 |
### 示例:帧同步移动速度计算
<div class="bp-graph" data-connections='[{"from":"f32-speed","to":"f32-mul-a","type":"fixed32"},{"from":"f32-dt","to":"f32-mul-b","type":"fixed32"},{"from":"f32-mul-result","to":"f32-tofloat","type":"fixed32"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="position: absolute; left: 20px; top: 10px; width: 150px;">
<div class="bp-node-header math" style="background: #9C27B0;">Fixed32 From Float</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">Value</span>
<span class="bp-pin-value">5.0</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="f32-speed"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
<span class="bp-pin-label">Speed</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 20px; top: 160px; width: 150px;">
<div class="bp-node-header math" style="background: #9C27B0;">Fixed32 From Float</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">Value</span>
<span class="bp-pin-value">0.016</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="f32-dt"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
<span class="bp-pin-label">DeltaTime</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 240px; top: 75px; width: 120px;">
<div class="bp-node-header math" style="background: #9C27B0;">Fixed32 *</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="f32-mul-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
<span class="bp-pin-label">A</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="f32-mul-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
<span class="bp-pin-label">B</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="f32-mul-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 430px; top: 75px; width: 150px;">
<div class="bp-node-header math" style="background: #9C27B0;">Fixed32 To Float</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="f32-tofloat"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#9C27B0"/></svg></span>
<span class="bp-pin-label">Fixed</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">Float</span>
</div>
</div>
</div>
</div>
---
## FixedVector2 定点向量节点
定点向量运算,用于确定性物理计算,适用于帧同步。
### 节点列表
| 节点 | 说明 | 输入 | 输出 |
|------|------|------|------|
| `Make FixedVector2` | 从 X, Y 浮点数创建 | X, Y | FixedVector2 |
| `Break FixedVector2` | 分解为 X, Y 浮点数 | Vector | X, Y |
| `FixedVector2 +` | 向量加法 | A, B | FixedVector2 |
| `FixedVector2 -` | 向量减法 | A, B | FixedVector2 |
| `FixedVector2 *` | 按 Fixed32 缩放 | Vector, Scalar | FixedVector2 |
| `FixedVector2 Negate` | 取反 | Vector | FixedVector2 |
| `FixedVector2 Length` | 获取长度 | Vector | Fixed32 |
| `FixedVector2 Normalize` | 归一化 | Vector | FixedVector2 |
| `FixedVector2 Dot` | 点积 | A, B | Fixed32 |
| `FixedVector2 Cross` | 2D 叉积 | A, B | Fixed32 |
| `FixedVector2 Distance` | 两点距离 | A, B | Fixed32 |
| `FixedVector2 Lerp` | 线性插值 | A, B, T | FixedVector2 |
### 示例:确定性位置更新
<div class="bp-graph" data-connections='[{"from":"fv2-pos","to":"fv2-add-a","type":"fixedvector2"},{"from":"fv2-vel","to":"fv2-add-b","type":"fixedvector2"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="position: absolute; left: 20px; top: 10px; width: 150px;">
<div class="bp-node-header math" style="background: #673AB7;">Make FixedVector2</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">X</span>
<span class="bp-pin-value">10</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">Y</span>
<span class="bp-pin-value">20</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="fv2-pos"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
<span class="bp-pin-label">Position</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 20px; top: 180px; width: 150px;">
<div class="bp-node-header math" style="background: #673AB7;">Make FixedVector2</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">X</span>
<span class="bp-pin-value">1</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">Y</span>
<span class="bp-pin-value">0</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="fv2-vel"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
<span class="bp-pin-label">Velocity</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 250px; top: 90px; width: 140px;">
<div class="bp-node-header math" style="background: #673AB7;">FixedVector2 +</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="fv2-add-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
<span class="bp-pin-label">A</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="fv2-add-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#673AB7"/></svg></span>
<span class="bp-pin-label">B</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="#673AB7"/></svg></span>
<span class="bp-pin-label">New Position</span>
</div>
</div>
</div>
</div>
---
## Color 颜色节点
颜色创建与操作节点。
### 节点列表
| 节点 | 说明 | 输入 | 输出 |
|------|------|------|------|
| `Make Color` | 从 RGBA 创建 | R, G, B, A | Color |
| `Break Color` | 分解为 RGBA | Color | R, G, B, A |
| `Color From Hex` | 从十六进制字符串创建 | Hex | Color |
| `Color To Hex` | 转换为十六进制字符串 | Color | String |
| `Color From HSL` | 从 HSL 创建 | H, S, L | Color |
| `Color To HSL` | 转换为 HSL | Color | H, S, L |
| `Color Lerp` | 颜色插值 | A, B, T | Color |
| `Color Lighten` | 提亮 | Color, Amount | Color |
| `Color Darken` | 变暗 | Color, Amount | Color |
| `Color Saturate` | 增加饱和度 | Color, Amount | Color |
| `Color Desaturate` | 降低饱和度 | Color, Amount | Color |
| `Color Invert` | 反色 | Color | Color |
| `Color Grayscale` | 灰度化 | Color | Color |
| `Color Luminance` | 获取亮度 | Color | Float |
### 颜色常量
| 节点 | 值 |
|------|------|
| `Color White` | (1, 1, 1, 1) |
| `Color Black` | (0, 0, 0, 1) |
| `Color Red` | (1, 0, 0, 1) |
| `Color Green` | (0, 1, 0, 1) |
| `Color Blue` | (0, 0, 1, 1) |
| `Color Transparent` | (0, 0, 0, 0) |
### 示例:颜色过渡动画
<div class="bp-graph" data-connections='[{"from":"color-a","to":"color-lerp-a","type":"color"},{"from":"color-b","to":"color-lerp-b","type":"color"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="position: absolute; left: 20px; top: 10px; width: 120px;">
<div class="bp-node-header math" style="background: #FF9800;">Color Red</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="color-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
<span class="bp-pin-label">Color</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 20px; top: 130px; width: 120px;">
<div class="bp-node-header math" style="background: #FF9800;">Color Blue</div>
<div class="bp-node-body">
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="color-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
<span class="bp-pin-label">Color</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 220px; top: 50px; width: 130px;">
<div class="bp-node-header math" style="background: #FF9800;">Color Lerp</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="color-lerp-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
<span class="bp-pin-label">A</span>
</div>
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="color-lerp-b"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
<span class="bp-pin-label">B</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">T</span>
<span class="bp-pin-value">0.5</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="#FF9800"/></svg></span>
<span class="bp-pin-label">Result</span>
</div>
</div>
</div>
</div>
### 示例:从 Hex 创建颜色
<div class="bp-graph" data-connections='[{"from":"hex-color","to":"break-color","type":"color"}]'>
<svg class="bp-connections"></svg>
<div class="bp-node" style="position: absolute; left: 20px; top: 30px; width: 150px;">
<div class="bp-node-header math" style="background: #FF9800;">Color From Hex</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="#e060e0" stroke-width="2"/></svg></span>
<span class="bp-pin-label">Hex</span>
<span class="bp-pin-value">"#FF5722"</span>
</div>
<div class="bp-pin-row output">
<span class="bp-pin" data-pin="hex-color"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
<span class="bp-pin-label">Color</span>
</div>
</div>
</div>
<div class="bp-node" style="position: absolute; left: 250px; top: 20px; width: 130px;">
<div class="bp-node-header math" style="background: #FF9800;">Break Color</div>
<div class="bp-node-body">
<div class="bp-pin-row input">
<span class="bp-pin" data-pin="break-color"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#FF9800"/></svg></span>
<span class="bp-pin-label">Color</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">R</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">G</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">B</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">A</span>
</div>
</div>
</div>
</div>
---
## 相关文档
- [蓝图节点参考](/modules/blueprint/nodes) - 核心蓝图节点
- [蓝图编辑器指南](/modules/blueprint/editor-guide) - 编辑器使用方法
- [自定义节点](/modules/blueprint/custom-nodes) - 创建自定义节点

View File

@@ -0,0 +1,79 @@
---
title: "数学库"
description: "ESEngine 数学库 - Vector2, Fixed32, FixedVector2, Color 等数学类型"
---
`@esengine/ecs-framework-math` 模块提供游戏开发常用的数学类型和运算。
## 核心类型
| 类型 | 说明 |
|------|------|
| `Vector2` | 2D 浮点向量,用于位置、速度、方向 |
| `Fixed32` | Q16.16 定点数,用于帧同步确定性计算 |
| `FixedVector2` | 2D 定点向量,用于确定性物理 |
| `Color` | RGBA 颜色 |
## 功能特性
### Vector2
- 加法、减法、缩放
- 点积、叉积
- 长度、归一化
- 距离、插值
- 旋转、角度转换
### Fixed32 定点数
专为帧同步网络游戏设计,保证跨平台计算一致性:
- 基本运算:加、减、乘、除
- 数学函数:绝对值、平方根、取整
- 比较、钳制、插值
- 常量0、1、0.5、π、2π
### Color 颜色
- RGB/RGBA 创建与分解
- Hex 十六进制转换
- HSL 色彩空间转换
- 颜色操作:提亮、变暗、饱和度调整
- 颜色混合与插值
## 蓝图支持
数学库提供了丰富的蓝图节点,详见:
- [数学库蓝图节点](/modules/math/blueprint-nodes)
## 安装
```bash
pnpm add @esengine/ecs-framework-math
```
## 基本用法
```typescript
import { Vector2, Fixed32, FixedVector2, Color } from '@esengine/ecs-framework-math';
// Vector2
const pos = new Vector2(10, 20);
const dir = pos.normalized();
// Fixed32 (帧同步)
const speed = Fixed32.from(5.0);
const dt = Fixed32.from(0.016);
const distance = speed.mul(dt);
// FixedVector2
const fixedPos = FixedVector2.from(10, 20);
const fixedVel = FixedVector2.from(1, 0);
const newPos = fixedPos.add(fixedVel);
// Color
const red = Color.RED;
const blue = Color.BLUE;
const purple = Color.lerp(red, blue, 0.5);
```

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

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

@@ -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,261 @@ 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; }
.bp-conn.vector2 { stroke: #2196F3; }
.bp-conn.fixed32 { stroke: #9C27B0; }
.bp-conn.fixedvector2 { stroke: #673AB7; }
.bp-conn.color { stroke: #FF9800; }
/* ==================== 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

@@ -1,5 +1,43 @@
# @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

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/node-editor",
"version": "1.2.2",
"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",

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

@@ -4,8 +4,10 @@ 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,11 @@ 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
// 挂载后强制重渲染以确保连接线正确绘制
@@ -137,6 +162,64 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
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 完全渲染
@@ -144,7 +227,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
forceUpdate(n => n + 1);
});
return () => cancelAnimationFrame(rafId);
}, [graph.id, collapsedNodesKey]);
}, [graph.id, collapsedNodesKey, groupsKey]);
/**
* Converts screen coordinates to canvas coordinates
@@ -166,6 +249,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
*
* 直接从节点位置和引脚在节点内的相对位置计算,不依赖 DOM 测量
* 当节点收缩时,返回节点头部的位置
* 当节点在折叠组中时,返回组节点的位置
*/
const getPinPosition = useCallback((pinId: string): Position | undefined => {
// First, find which node this pin belongs to
@@ -304,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;
@@ -315,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
@@ -327,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
@@ -361,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
@@ -472,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]);
@@ -490,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
* 处理引脚值变化
@@ -546,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}
@@ -558,7 +836,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onConnectionContextMenu={handleConnectionContextMenu}
/>
{/* Nodes (节点) */}
{/* All Nodes (所有节点) */}
{graph.nodes.map(node => (
<MemoizedGraphNodeComponent
key={node.id}
@@ -580,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

@@ -23,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

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

View File

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

View File

@@ -1,5 +1,48 @@
# @esengine/blueprint
## 4.5.0
### Minor Changes
- [#447](https://github.com/esengine/esengine/pull/447) [`4e66bd8`](https://github.com/esengine/esengine/commit/4e66bd8e2be80b366a7723dcc48b99df0457aed4) Thanks [@esengine](https://github.com/esengine)! - feat(blueprint): add Schema type system and @BlueprintArray decorator
- Add `Schema` fluent API for defining complex data types:
- Primitive types: `Schema.float()`, `Schema.int()`, `Schema.string()`, `Schema.boolean()`, `Schema.vector2()`, `Schema.vector3()`
- Composite types: `Schema.object()`, `Schema.array()`, `Schema.enum()`, `Schema.ref()`
- Support for constraints: `min`, `max`, `step`, `defaultValue`, `placeholder`, etc.
- Add `@BlueprintArray` decorator for array properties:
- `itemSchema`: Define schema for array items using Schema API
- `reorderable`: Allow drag-and-drop reordering
- `exposeElementPorts`: Create individual ports for each array element
- `portNameTemplate`: Custom naming for element ports (e.g., "Waypoint {index1}")
- Update documentation with examples and usage guide
## 4.4.0
### Minor Changes
- [#438](https://github.com/esengine/esengine/pull/438) [`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b) Thanks [@esengine](https://github.com/esengine)! - feat(node-editor): add visual group box for organizing nodes
- Add NodeGroup model with dynamic bounds calculation based on node pin counts
- Add GroupNodeComponent for rendering group boxes behind nodes
- Groups automatically resize to wrap contained nodes
- Dragging group header moves all nodes inside together
- Support group serialization/deserialization
- Export `estimateNodeHeight` and `NodeBounds` for accurate size calculation
feat(blueprint): add comprehensive math and logic nodes
Math nodes:
- Modulo, Abs, Min, Max, Power, Sqrt
- Floor, Ceil, Round, Sign, Negate
- Sin, Cos, Tan, Asin, Acos, Atan, Atan2
- DegToRad, RadToDeg, Lerp, InverseLerp
- Clamp, Wrap, RandomRange, RandomInt
Logic nodes:
- Equal, NotEqual, GreaterThan, GreaterThanOrEqual
- LessThan, LessThanOrEqual, InRange
- AND, OR, NOT, XOR, NAND
- IsNull, Select (ternary)
## 4.3.0
### Minor Changes

View File

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

View File

@@ -7,6 +7,7 @@
* - ecs: ECS 操作Entity, Component, Flow
* - variables: 变量读写
* - math: 数学运算
* - logic: 比较和逻辑运算
* - time: 时间工具
* - debug: 调试工具
*
@@ -15,6 +16,7 @@
* - ecs: ECS operations (Entity, Component, Flow)
* - variables: Variable get/set
* - math: Math operations
* - logic: Comparison and logical operations
* - time: Time utilities
* - debug: Debug utilities
*/
@@ -31,6 +33,9 @@ export * from './variables';
// Math operations | 数学运算
export * from './math';
// Logic operations | 逻辑运算
export * from './logic';
// Time utilities | 时间工具
export * from './time';

View File

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

View File

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

View File

@@ -120,3 +120,766 @@ export class DivideExecutor implements INodeExecutor {
return { outputs: { result: a / b } };
}
}
// ============================================================================
// Modulo Node (取模节点)
// ============================================================================
export const ModuloTemplate: BlueprintNodeTemplate = {
type: 'Modulo',
title: 'Modulo',
category: 'math',
color: '#4CAF50',
description: 'Returns the remainder of A divided by B (返回 A 除以 B 的余数)',
keywords: ['modulo', 'mod', 'remainder', '%', 'math'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 1 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(ModuloTemplate)
export class ModuloExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 1));
if (b === 0) return { outputs: { result: 0 } };
return { outputs: { result: a % b } };
}
}
// ============================================================================
// Absolute Value Node (绝对值节点)
// ============================================================================
export const AbsTemplate: BlueprintNodeTemplate = {
type: 'Abs',
title: 'Absolute',
category: 'math',
color: '#4CAF50',
description: 'Returns the absolute value (返回绝对值)',
keywords: ['abs', 'absolute', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(AbsTemplate)
export class AbsExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.abs(value) } };
}
}
// ============================================================================
// Min Node (最小值节点)
// ============================================================================
export const MinTemplate: BlueprintNodeTemplate = {
type: 'Min',
title: 'Min',
category: 'math',
color: '#4CAF50',
description: 'Returns the smaller of two values (返回两个值中较小的一个)',
keywords: ['min', 'minimum', 'smaller', 'math'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(MinTemplate)
export class MinExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 0));
return { outputs: { result: Math.min(a, b) } };
}
}
// ============================================================================
// Max Node (最大值节点)
// ============================================================================
export const MaxTemplate: BlueprintNodeTemplate = {
type: 'Max',
title: 'Max',
category: 'math',
color: '#4CAF50',
description: 'Returns the larger of two values (返回两个值中较大的一个)',
keywords: ['max', 'maximum', 'larger', 'math'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(MaxTemplate)
export class MaxExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 0));
return { outputs: { result: Math.max(a, b) } };
}
}
// ============================================================================
// Clamp Node (限制范围节点)
// ============================================================================
export const ClampTemplate: BlueprintNodeTemplate = {
type: 'Clamp',
title: 'Clamp',
category: 'math',
color: '#4CAF50',
description: 'Clamps a value between min and max (将值限制在最小和最大之间)',
keywords: ['clamp', 'limit', 'range', 'bound', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 },
{ name: 'min', type: 'float', displayName: 'Min', defaultValue: 0 },
{ name: 'max', type: 'float', displayName: 'Max', defaultValue: 1 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(ClampTemplate)
export class ClampExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
const min = Number(context.evaluateInput(node.id, 'min', 0));
const max = Number(context.evaluateInput(node.id, 'max', 1));
return { outputs: { result: Math.max(min, Math.min(max, value)) } };
}
}
// ============================================================================
// Lerp Node (线性插值节点)
// ============================================================================
export const LerpTemplate: BlueprintNodeTemplate = {
type: 'Lerp',
title: 'Lerp',
category: 'math',
color: '#4CAF50',
description: 'Linear interpolation between A and B (A 和 B 之间的线性插值)',
keywords: ['lerp', 'interpolate', 'blend', 'mix', 'math'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 1 },
{ name: 't', type: 'float', displayName: 'Alpha', defaultValue: 0.5 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(LerpTemplate)
export class LerpExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 1));
const t = Number(context.evaluateInput(node.id, 't', 0.5));
return { outputs: { result: a + (b - a) * t } };
}
}
// ============================================================================
// Random Range Node (随机范围节点)
// ============================================================================
export const RandomRangeTemplate: BlueprintNodeTemplate = {
type: 'RandomRange',
title: 'Random Range',
category: 'math',
color: '#4CAF50',
description: 'Returns a random number between min and max (返回 min 和 max 之间的随机数)',
keywords: ['random', 'range', 'rand', 'math'],
isPure: true,
inputs: [
{ name: 'min', type: 'float', displayName: 'Min', defaultValue: 0 },
{ name: 'max', type: 'float', displayName: 'Max', defaultValue: 1 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(RandomRangeTemplate)
export class RandomRangeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const min = Number(context.evaluateInput(node.id, 'min', 0));
const max = Number(context.evaluateInput(node.id, 'max', 1));
return { outputs: { result: min + Math.random() * (max - min) } };
}
}
// ============================================================================
// Random Integer Node (随机整数节点)
// ============================================================================
export const RandomIntTemplate: BlueprintNodeTemplate = {
type: 'RandomInt',
title: 'Random Integer',
category: 'math',
color: '#4CAF50',
description: 'Returns a random integer between min and max inclusive (返回 min 和 max 之间的随机整数,包含边界)',
keywords: ['random', 'int', 'integer', 'rand', 'math'],
isPure: true,
inputs: [
{ name: 'min', type: 'int', displayName: 'Min', defaultValue: 0 },
{ name: 'max', type: 'int', displayName: 'Max', defaultValue: 10 }
],
outputs: [
{ name: 'result', type: 'int', displayName: 'Result' }
]
};
@RegisterNode(RandomIntTemplate)
export class RandomIntExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const min = Math.floor(Number(context.evaluateInput(node.id, 'min', 0)));
const max = Math.floor(Number(context.evaluateInput(node.id, 'max', 10)));
return { outputs: { result: Math.floor(min + Math.random() * (max - min + 1)) } };
}
}
// ============================================================================
// Power Node (幂运算节点)
// ============================================================================
export const PowerTemplate: BlueprintNodeTemplate = {
type: 'Power',
title: 'Power',
category: 'math',
color: '#4CAF50',
description: 'Returns base raised to the power of exponent (返回底数的指数次幂)',
keywords: ['power', 'pow', 'exponent', '^', 'math'],
isPure: true,
inputs: [
{ name: 'base', type: 'float', displayName: 'Base', defaultValue: 2 },
{ name: 'exponent', type: 'float', displayName: 'Exponent', defaultValue: 2 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(PowerTemplate)
export class PowerExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const base = Number(context.evaluateInput(node.id, 'base', 2));
const exponent = Number(context.evaluateInput(node.id, 'exponent', 2));
return { outputs: { result: Math.pow(base, exponent) } };
}
}
// ============================================================================
// Square Root Node (平方根节点)
// ============================================================================
export const SqrtTemplate: BlueprintNodeTemplate = {
type: 'Sqrt',
title: 'Square Root',
category: 'math',
color: '#4CAF50',
description: 'Returns the square root (返回平方根)',
keywords: ['sqrt', 'square', 'root', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 4 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(SqrtTemplate)
export class SqrtExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 4));
return { outputs: { result: Math.sqrt(Math.abs(value)) } };
}
}
// ============================================================================
// Floor Node (向下取整节点)
// ============================================================================
export const FloorTemplate: BlueprintNodeTemplate = {
type: 'Floor',
title: 'Floor',
category: 'math',
color: '#4CAF50',
description: 'Rounds down to the nearest integer (向下取整)',
keywords: ['floor', 'round', 'down', 'int', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'int', displayName: 'Result' }
]
};
@RegisterNode(FloorTemplate)
export class FloorExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.floor(value) } };
}
}
// ============================================================================
// Ceil Node (向上取整节点)
// ============================================================================
export const CeilTemplate: BlueprintNodeTemplate = {
type: 'Ceil',
title: 'Ceil',
category: 'math',
color: '#4CAF50',
description: 'Rounds up to the nearest integer (向上取整)',
keywords: ['ceil', 'ceiling', 'round', 'up', 'int', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'int', displayName: 'Result' }
]
};
@RegisterNode(CeilTemplate)
export class CeilExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.ceil(value) } };
}
}
// ============================================================================
// Round Node (四舍五入节点)
// ============================================================================
export const RoundTemplate: BlueprintNodeTemplate = {
type: 'Round',
title: 'Round',
category: 'math',
color: '#4CAF50',
description: 'Rounds to the nearest integer (四舍五入到最近的整数)',
keywords: ['round', 'int', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'int', displayName: 'Result' }
]
};
@RegisterNode(RoundTemplate)
export class RoundExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.round(value) } };
}
}
// ============================================================================
// Negate Node (取反节点)
// ============================================================================
export const NegateTemplate: BlueprintNodeTemplate = {
type: 'Negate',
title: 'Negate',
category: 'math',
color: '#4CAF50',
description: 'Returns the negative of a value (返回值的负数)',
keywords: ['negate', 'negative', 'minus', '-', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(NegateTemplate)
export class NegateExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: -value } };
}
}
// ============================================================================
// Sign Node (符号节点)
// ============================================================================
export const SignTemplate: BlueprintNodeTemplate = {
type: 'Sign',
title: 'Sign',
category: 'math',
color: '#4CAF50',
description: 'Returns -1, 0, or 1 based on the sign of the value (根据值的符号返回 -1、0 或 1)',
keywords: ['sign', 'positive', 'negative', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'int', displayName: 'Result' }
]
};
@RegisterNode(SignTemplate)
export class SignExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.sign(value) } };
}
}
// ============================================================================
// Wrap Node (循环限制节点)
// ============================================================================
export const WrapTemplate: BlueprintNodeTemplate = {
type: 'Wrap',
title: 'Wrap',
category: 'math',
color: '#4CAF50',
description: 'Wraps value to stay within min and max range (将值循环限制在范围内)',
keywords: ['wrap', 'loop', 'cycle', 'range', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 },
{ name: 'min', type: 'float', displayName: 'Min', defaultValue: 0 },
{ name: 'max', type: 'float', displayName: 'Max', defaultValue: 1 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(WrapTemplate)
export class WrapExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
const min = Number(context.evaluateInput(node.id, 'min', 0));
const max = Number(context.evaluateInput(node.id, 'max', 1));
const range = max - min;
if (range <= 0) return { outputs: { result: min } };
const wrapped = ((value - min) % range + range) % range + min;
return { outputs: { result: wrapped } };
}
}
// ============================================================================
// Sin Node (正弦节点)
// ============================================================================
export const SinTemplate: BlueprintNodeTemplate = {
type: 'Sin',
title: 'Sin',
category: 'math',
color: '#4CAF50',
description: 'Returns the sine of angle in radians (返回弧度角的正弦值)',
keywords: ['sin', 'sine', 'trig', 'math'],
isPure: true,
inputs: [
{ name: 'radians', type: 'float', displayName: 'Radians', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(SinTemplate)
export class SinExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const radians = Number(context.evaluateInput(node.id, 'radians', 0));
return { outputs: { result: Math.sin(radians) } };
}
}
// ============================================================================
// Cos Node (余弦节点)
// ============================================================================
export const CosTemplate: BlueprintNodeTemplate = {
type: 'Cos',
title: 'Cos',
category: 'math',
color: '#4CAF50',
description: 'Returns the cosine of angle in radians (返回弧度角的余弦值)',
keywords: ['cos', 'cosine', 'trig', 'math'],
isPure: true,
inputs: [
{ name: 'radians', type: 'float', displayName: 'Radians', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(CosTemplate)
export class CosExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const radians = Number(context.evaluateInput(node.id, 'radians', 0));
return { outputs: { result: Math.cos(radians) } };
}
}
// ============================================================================
// Tan Node (正切节点)
// ============================================================================
export const TanTemplate: BlueprintNodeTemplate = {
type: 'Tan',
title: 'Tan',
category: 'math',
color: '#4CAF50',
description: 'Returns the tangent of angle in radians (返回弧度角的正切值)',
keywords: ['tan', 'tangent', 'trig', 'math'],
isPure: true,
inputs: [
{ name: 'radians', type: 'float', displayName: 'Radians', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(TanTemplate)
export class TanExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const radians = Number(context.evaluateInput(node.id, 'radians', 0));
return { outputs: { result: Math.tan(radians) } };
}
}
// ============================================================================
// Asin Node (反正弦节点)
// ============================================================================
export const AsinTemplate: BlueprintNodeTemplate = {
type: 'Asin',
title: 'Asin',
category: 'math',
color: '#4CAF50',
description: 'Returns the arc sine in radians (返回反正弦值,单位为弧度)',
keywords: ['asin', 'arc', 'sine', 'inverse', 'trig', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Radians' }
]
};
@RegisterNode(AsinTemplate)
export class AsinExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.asin(Math.max(-1, Math.min(1, value))) } };
}
}
// ============================================================================
// Acos Node (反余弦节点)
// ============================================================================
export const AcosTemplate: BlueprintNodeTemplate = {
type: 'Acos',
title: 'Acos',
category: 'math',
color: '#4CAF50',
description: 'Returns the arc cosine in radians (返回反余弦值,单位为弧度)',
keywords: ['acos', 'arc', 'cosine', 'inverse', 'trig', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Radians' }
]
};
@RegisterNode(AcosTemplate)
export class AcosExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.acos(Math.max(-1, Math.min(1, value))) } };
}
}
// ============================================================================
// Atan Node (反正切节点)
// ============================================================================
export const AtanTemplate: BlueprintNodeTemplate = {
type: 'Atan',
title: 'Atan',
category: 'math',
color: '#4CAF50',
description: 'Returns the arc tangent in radians (返回反正切值,单位为弧度)',
keywords: ['atan', 'arc', 'tangent', 'inverse', 'trig', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Radians' }
]
};
@RegisterNode(AtanTemplate)
export class AtanExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.atan(value) } };
}
}
// ============================================================================
// Atan2 Node (两参数反正切节点)
// ============================================================================
export const Atan2Template: BlueprintNodeTemplate = {
type: 'Atan2',
title: 'Atan2',
category: 'math',
color: '#4CAF50',
description: 'Returns the angle in radians between the positive X axis and the point (x, y) (返回点(x,y)与正X轴之间的弧度角)',
keywords: ['atan2', 'angle', 'direction', 'trig', 'math'],
isPure: true,
inputs: [
{ name: 'y', type: 'float', displayName: 'Y', defaultValue: 0 },
{ name: 'x', type: 'float', displayName: 'X', defaultValue: 1 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Radians' }
]
};
@RegisterNode(Atan2Template)
export class Atan2Executor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const y = Number(context.evaluateInput(node.id, 'y', 0));
const x = Number(context.evaluateInput(node.id, 'x', 1));
return { outputs: { result: Math.atan2(y, x) } };
}
}
// ============================================================================
// Degrees to Radians Node (角度转弧度节点)
// ============================================================================
export const DegToRadTemplate: BlueprintNodeTemplate = {
type: 'DegToRad',
title: 'Degrees to Radians',
category: 'math',
color: '#4CAF50',
description: 'Converts degrees to radians (将角度转换为弧度)',
keywords: ['degrees', 'radians', 'convert', 'angle', 'math'],
isPure: true,
inputs: [
{ name: 'degrees', type: 'float', displayName: 'Degrees', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Radians' }
]
};
@RegisterNode(DegToRadTemplate)
export class DegToRadExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const degrees = Number(context.evaluateInput(node.id, 'degrees', 0));
return { outputs: { result: degrees * (Math.PI / 180) } };
}
}
// ============================================================================
// Radians to Degrees Node (弧度转角度节点)
// ============================================================================
export const RadToDegTemplate: BlueprintNodeTemplate = {
type: 'RadToDeg',
title: 'Radians to Degrees',
category: 'math',
color: '#4CAF50',
description: 'Converts radians to degrees (将弧度转换为角度)',
keywords: ['radians', 'degrees', 'convert', 'angle', 'math'],
isPure: true,
inputs: [
{ name: 'radians', type: 'float', displayName: 'Radians', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Degrees' }
]
};
@RegisterNode(RadToDegTemplate)
export class RadToDegExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const radians = Number(context.evaluateInput(node.id, 'radians', 0));
return { outputs: { result: radians * (180 / Math.PI) } };
}
}
// ============================================================================
// Inverse Lerp Node (反向线性插值节点)
// ============================================================================
export const InverseLerpTemplate: BlueprintNodeTemplate = {
type: 'InverseLerp',
title: 'Inverse Lerp',
category: 'math',
color: '#4CAF50',
description: 'Returns the percentage of Value between A and B (返回值在 A 和 B 之间的百分比位置)',
keywords: ['inverse', 'lerp', 'percentage', 'ratio', 'math'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 1 },
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0.5 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Alpha (0-1)' }
]
};
@RegisterNode(InverseLerpTemplate)
export class InverseLerpExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 1));
const value = Number(context.evaluateInput(node.id, 'value', 0.5));
if (b === a) return { outputs: { result: 0 } };
return { outputs: { result: (value - a) / (b - a) } };
}
}

View File

@@ -1,118 +1,75 @@
/**
* @zh 蓝图装饰器 - 用于标记可在蓝图中使用的组件、属性和方法
* @en Blueprint Decorators - Mark components, properties and methods for blueprint use
*
* @example
* ```typescript
* import { BlueprintExpose, BlueprintProperty, BlueprintMethod } from '@esengine/blueprint';
*
* @ECSComponent('Health')
* @BlueprintExpose({ displayName: '生命值组件', category: 'gameplay' })
* export class HealthComponent extends Component {
*
* @BlueprintProperty({ displayName: '当前生命值', type: 'float' })
* current: number = 100;
*
* @BlueprintProperty({ displayName: '最大生命值', type: 'float', readonly: true })
* max: number = 100;
*
* @BlueprintMethod({
* displayName: '治疗',
* params: [{ name: 'amount', type: 'float' }]
* })
* heal(amount: number): void {
* this.current = Math.min(this.current + amount, this.max);
* }
*
* @BlueprintMethod({
* displayName: '受伤',
* params: [{ name: 'amount', type: 'float' }],
* returnType: 'bool'
* })
* takeDamage(amount: number): boolean {
* this.current -= amount;
* return this.current <= 0;
* }
* }
* ```
*/
import type { BlueprintPinType } from '../types/pins';
import type { PropertySchema, ArraySchema, ObjectSchema } from '../types/schema';
// ============================================================================
// Types | 类型定义
// Types
// ============================================================================
/**
* @zh 参数定义
* @en Parameter definition
*/
export interface BlueprintParamDef {
/** @zh 参数名称 @en Parameter name */
name: string;
/** @zh 显示名称 @en Display name */
displayName?: string;
/** @zh 引脚类型 @en Pin type */
type?: BlueprintPinType;
/** @zh 默认值 @en Default value */
defaultValue?: unknown;
}
/**
* @zh 蓝图暴露选项
* @en Blueprint expose options
*/
export interface BlueprintExposeOptions {
/** @zh 组件显示名称 @en Component display name */
displayName?: string;
/** @zh 组件描述 @en Component description */
description?: string;
/** @zh 组件分类 @en Component category */
category?: string;
/** @zh 组件颜色 @en Component color */
color?: string;
/** @zh 组件图标 @en Component icon */
icon?: string;
}
/**
* @zh 蓝图属性选项
* @en Blueprint property options
*/
export interface BlueprintPropertyOptions {
/** @zh 属性显示名称 @en Property display name */
displayName?: string;
/** @zh 属性描述 @en Property description */
description?: string;
/** @zh 引脚类型 @en Pin type */
type?: BlueprintPinType;
/** @zh 是否只读(不生成 Set 节点)@en Readonly (no Set node generated) */
readonly?: boolean;
/** @zh 默认值 @en Default value */
defaultValue?: unknown;
}
/**
* @zh 蓝图方法选项
* @en Blueprint method options
*/
export interface BlueprintMethodOptions {
/** @zh 方法显示名称 @en Method display name */
displayName?: string;
/** @zh 方法描述 @en Method description */
description?: string;
/** @zh 是否是纯函数(无副作用)@en Is pure function (no side effects) */
isPure?: boolean;
/** @zh 参数列表 @en Parameter list */
params?: BlueprintParamDef[];
/** @zh 返回值类型 @en Return type */
returnType?: BlueprintPinType;
}
/**
* @zh 属性元数据
* @en Property metadata
* @zh 蓝图数组属性选项
* @en Blueprint array property options
*/
export interface BlueprintArrayOptions {
displayName?: string;
description?: string;
itemSchema: PropertySchema;
reorderable?: boolean;
collapsible?: boolean;
minItems?: number;
maxItems?: number;
defaultValue?: unknown[];
itemLabel?: string;
exposeElementPorts?: boolean;
portNameTemplate?: string;
}
/**
* @zh 蓝图对象属性选项
* @en Blueprint object property options
*/
export interface BlueprintObjectOptions {
displayName?: string;
description?: string;
properties: Record<string, PropertySchema>;
collapsible?: boolean;
}
export interface PropertyMetadata {
propertyKey: string;
displayName: string;
@@ -120,12 +77,12 @@ export interface PropertyMetadata {
pinType: BlueprintPinType;
readonly: boolean;
defaultValue?: unknown;
schema?: PropertySchema;
isDynamicArray?: boolean;
exposeElementPorts?: boolean;
portNameTemplate?: string;
}
/**
* @zh 方法元数据
* @en Method metadata
*/
export interface MethodMetadata {
methodKey: string;
displayName: string;
@@ -135,10 +92,6 @@ export interface MethodMetadata {
returnType: BlueprintPinType;
}
/**
* @zh 组件蓝图元数据
* @en Component blueprint metadata
*/
export interface ComponentBlueprintMetadata extends BlueprintExposeOptions {
componentName: string;
properties: PropertyMetadata[];
@@ -146,41 +99,25 @@ export interface ComponentBlueprintMetadata extends BlueprintExposeOptions {
}
// ============================================================================
// Registry | 注册表
// Registry
// ============================================================================
/**
* @zh 已注册的蓝图组件
* @en Registered blueprint components
*/
const registeredComponents = new Map<Function, ComponentBlueprintMetadata>();
/**
* @zh 获取所有已注册的蓝图组件
* @en Get all registered blueprint components
*/
export function getRegisteredBlueprintComponents(): Map<Function, ComponentBlueprintMetadata> {
return registeredComponents;
}
/**
* @zh 获取组件的蓝图元数据
* @en Get blueprint metadata for a component
*/
export function getBlueprintMetadata(componentClass: Function): ComponentBlueprintMetadata | undefined {
return registeredComponents.get(componentClass);
}
/**
* @zh 清除所有注册的蓝图组件(用于测试)
* @en Clear all registered blueprint components (for testing)
*/
export function clearRegisteredComponents(): void {
registeredComponents.clear();
}
// ============================================================================
// Internal Helpers | 内部辅助函数
// Internal Helpers
// ============================================================================
function getOrCreateMetadata(constructor: Function): ComponentBlueprintMetadata {
@@ -197,20 +134,9 @@ function getOrCreateMetadata(constructor: Function): ComponentBlueprintMetadata
}
// ============================================================================
// Decorators | 装饰器
// Decorators
// ============================================================================
/**
* @zh 标记组件可在蓝图中使用
* @en Mark component as usable in blueprint
*
* @example
* ```typescript
* @ECSComponent('Player')
* @BlueprintExpose({ displayName: '玩家', category: 'gameplay' })
* export class PlayerComponent extends Component { }
* ```
*/
export function BlueprintExpose(options: BlueprintExposeOptions = {}): ClassDecorator {
return function (target: Function) {
const metadata = getOrCreateMetadata(target);
@@ -220,19 +146,6 @@ export function BlueprintExpose(options: BlueprintExposeOptions = {}): ClassDeco
};
}
/**
* @zh 标记属性可在蓝图中访问
* @en Mark property as accessible in blueprint
*
* @example
* ```typescript
* @BlueprintProperty({ displayName: '生命值', type: 'float' })
* health: number = 100;
*
* @BlueprintProperty({ displayName: '名称', type: 'string', readonly: true })
* name: string = 'Player';
* ```
*/
export function BlueprintProperty(options: BlueprintPropertyOptions = {}): PropertyDecorator {
return function (target: Object, propertyKey: string | symbol) {
const key = String(propertyKey);
@@ -257,25 +170,108 @@ export function BlueprintProperty(options: BlueprintPropertyOptions = {}): Prope
}
/**
* @zh 标记方法可在蓝图中调用
* @en Mark method as callable in blueprint
* @zh 标记属性为蓝图数组(支持动态增删、排序)
* @en Mark property as blueprint array (supports dynamic add/remove, reorder)
*
* @example
* ```typescript
* @BlueprintMethod({
* displayName: '攻击',
* params: [
* { name: 'target', type: 'entity' },
* { name: 'damage', type: 'float' }
* ],
* returnType: 'bool'
* @BlueprintArray({
* displayName: '路径点',
* itemSchema: Schema.object({
* position: Schema.vector2(),
* waitTime: Schema.float({ min: 0, defaultValue: 1.0 })
* }),
* reorderable: true,
* exposeElementPorts: true,
* portNameTemplate: 'Point {index1}'
* })
* attack(target: Entity, damage: number): boolean { }
*
* @BlueprintMethod({ displayName: '获取速度', isPure: true, returnType: 'float' })
* getSpeed(): number { return this.speed; }
* waypoints: Waypoint[] = [];
* ```
*/
export function BlueprintArray(options: BlueprintArrayOptions): PropertyDecorator {
return function (target: Object, propertyKey: string | symbol) {
const key = String(propertyKey);
const metadata = getOrCreateMetadata(target.constructor);
const arraySchema: ArraySchema = {
type: 'array',
items: options.itemSchema,
defaultValue: options.defaultValue,
minItems: options.minItems,
maxItems: options.maxItems,
reorderable: options.reorderable,
collapsible: options.collapsible,
itemLabel: options.itemLabel
};
const propMeta: PropertyMetadata = {
propertyKey: key,
displayName: options.displayName ?? key,
description: options.description,
pinType: 'array',
readonly: false,
defaultValue: options.defaultValue,
schema: arraySchema,
isDynamicArray: true,
exposeElementPorts: options.exposeElementPorts,
portNameTemplate: options.portNameTemplate
};
const existingIndex = metadata.properties.findIndex(p => p.propertyKey === key);
if (existingIndex >= 0) {
metadata.properties[existingIndex] = propMeta;
} else {
metadata.properties.push(propMeta);
}
};
}
/**
* @zh 标记属性为蓝图对象(支持嵌套结构)
* @en Mark property as blueprint object (supports nested structure)
*
* @example
* ```typescript
* @BlueprintObject({
* displayName: '变换',
* properties: {
* position: Schema.vector2(),
* rotation: Schema.float(),
* scale: Schema.vector2({ defaultValue: { x: 1, y: 1 } })
* }
* })
* transform: Transform;
* ```
*/
export function BlueprintObject(options: BlueprintObjectOptions): PropertyDecorator {
return function (target: Object, propertyKey: string | symbol) {
const key = String(propertyKey);
const metadata = getOrCreateMetadata(target.constructor);
const objectSchema: ObjectSchema = {
type: 'object',
properties: options.properties,
collapsible: options.collapsible
};
const propMeta: PropertyMetadata = {
propertyKey: key,
displayName: options.displayName ?? key,
description: options.description,
pinType: 'object',
readonly: false,
schema: objectSchema
};
const existingIndex = metadata.properties.findIndex(p => p.propertyKey === key);
if (existingIndex >= 0) {
metadata.properties[existingIndex] = propMeta;
} else {
metadata.properties.push(propMeta);
}
};
}
export function BlueprintMethod(options: BlueprintMethodOptions = {}): MethodDecorator {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
const key = String(propertyKey);
@@ -302,13 +298,9 @@ export function BlueprintMethod(options: BlueprintMethodOptions = {}): MethodDec
}
// ============================================================================
// Utility Functions | 工具函数
// Utility Functions
// ============================================================================
/**
* @zh 从 TypeScript 类型名推断蓝图引脚类型
* @en Infer blueprint pin type from TypeScript type name
*/
export function inferPinType(typeName: string): BlueprintPinType {
const typeMap: Record<string, BlueprintPinType> = {
'number': 'float',

View File

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

View File

@@ -1,3 +1,5 @@
export * from './pins';
export * from './nodes';
export * from './blueprint';
export * from './schema';
export * from './path-utils';

View File

@@ -4,6 +4,7 @@
*/
import { BlueprintPinDefinition } from './pins';
import { ObjectSchema } from './schema';
/**
* Node category for visual styling and organization
@@ -70,6 +71,23 @@ export interface BlueprintNodeTemplate {
/** Node color for visual distinction (节点颜色用于视觉区分) */
color?: string;
// ========== Schema Support (Schema 支持) ==========
/**
* @zh 节点数据 Schema - 定义节点存储的数据结构
* @en Node data schema - defines the data structure stored in the node
*
* @zh 当定义了 schema 时,节点数据将按照 schema 结构存储和验证
* @en When schema is defined, node data will be stored and validated according to the schema structure
*/
schema?: ObjectSchema;
/**
* @zh 动态数组路径列表 - 指定哪些数组支持动态增删元素
* @en Dynamic array paths - specifies which arrays support dynamic add/remove elements
*/
dynamicArrayPaths?: string[];
}
/**
@@ -96,6 +114,9 @@ export interface BlueprintNode {
/**
* Connection between two pins
* 两个引脚之间的连接
*
* @zh 引脚路径支持数组索引,如 "waypoints[0].position"
* @en Pin paths support array indices, e.g., "waypoints[0].position"
*/
export interface BlueprintConnection {
/** Unique connection ID (唯一连接ID) */
@@ -104,13 +125,19 @@ export interface BlueprintConnection {
/** Source node ID (源节点ID) */
fromNodeId: string;
/** Source pin name (源引脚名称) */
/**
* @zh 源引脚路径(支持数组索引如 "items[0].value"
* @en Source pin path (supports array indices like "items[0].value")
*/
fromPin: string;
/** Target node ID (目标节点ID) */
toNodeId: string;
/** Target pin name (目标引脚名称) */
/**
* @zh 目标引脚路径(支持数组索引如 "items[0].value"
* @en Target pin path (supports array indices like "items[0].value")
*/
toPin: string;
}

View File

@@ -0,0 +1,549 @@
/**
* @zh 蓝图路径工具
* @en Blueprint Path Utilities
*
* @zh 用于解析和操作数据路径,支持数组索引和嵌套属性访问
* @en Used to parse and manipulate data paths, supports array indices and nested property access
*/
// ============================================================================
// Path Types
// ============================================================================
/**
* @zh 路径部分类型
* @en Path part type
*/
export type PathPartType = 'property' | 'index' | 'wildcard';
/**
* @zh 路径部分
* @en Path part
*/
export interface PathPart {
type: PathPartType;
/** Property name (for 'property' type) */
name?: string;
/** Array index (for 'index' type) */
index?: number;
}
/**
* @zh 端口地址 - 解析后的路径结构
* @en Port address - parsed path structure
*/
export interface PortAddress {
/** Base property name (基础属性名) */
baseName: string;
/** Array indices [0, 2] represents arr[0][2] (数组索引路径) */
indices: number[];
/** Nested property path ['x', 'y'] (嵌套属性路径) */
subPath: string[];
/** Original path string (原始路径字符串) */
original: string;
}
// ============================================================================
// Path Parsing
// ============================================================================
/**
* @zh 解析路径字符串为部分数组
* @en Parse path string to parts array
*
* @example
* parsePath("waypoints[0].position.x")
* // => [
* // { type: 'property', name: 'waypoints' },
* // { type: 'index', index: 0 },
* // { type: 'property', name: 'position' },
* // { type: 'property', name: 'x' }
* // ]
*/
export function parsePath(path: string): PathPart[] {
const parts: PathPart[] = [];
const regex = /([^.\[\]]+)|\[(\*|\d+)\]/g;
let match;
while ((match = regex.exec(path)) !== null) {
if (match[1]) {
// Property name
parts.push({ type: 'property', name: match[1] });
} else if (match[2] === '*') {
// Wildcard
parts.push({ type: 'wildcard' });
} else {
// Array index
parts.push({ type: 'index', index: parseInt(match[2], 10) });
}
}
return parts;
}
/**
* @zh 解析端口路径字符串为 PortAddress
* @en Parse port path string to PortAddress
*
* @example
* parsePortPath("waypoints[0].position.x")
* // => { baseName: "waypoints", indices: [0], subPath: ["position", "x"], original: "..." }
*/
export function parsePortPath(path: string): PortAddress {
const result: PortAddress = {
baseName: '',
indices: [],
subPath: [],
original: path
};
const parts = parsePath(path);
let foundFirstIndex = false;
let afterIndices = false;
for (const part of parts) {
if (part.type === 'property') {
if (!foundFirstIndex) {
if (result.baseName) {
// Multiple properties before index - treat as nested base
result.baseName += '.' + part.name;
} else {
result.baseName = part.name!;
}
} else {
afterIndices = true;
result.subPath.push(part.name!);
}
} else if (part.type === 'index') {
foundFirstIndex = true;
if (!afterIndices) {
result.indices.push(part.index!);
} else {
// Index after property - encode in subPath
result.subPath.push(`[${part.index}]`);
}
}
}
return result;
}
/**
* @zh 构建路径字符串
* @en Build path string from parts
*/
export function buildPath(parts: PathPart[]): string {
let path = '';
for (const part of parts) {
switch (part.type) {
case 'property':
if (path && !path.endsWith('[')) {
path += '.';
}
path += part.name;
break;
case 'index':
path += `[${part.index}]`;
break;
case 'wildcard':
path += '[*]';
break;
}
}
return path;
}
/**
* @zh 从 PortAddress 构建路径字符串
* @en Build path string from PortAddress
*/
export function buildPortPath(address: PortAddress): string {
let path = address.baseName;
for (const index of address.indices) {
path += `[${index}]`;
}
if (address.subPath.length > 0) {
for (const sub of address.subPath) {
if (sub.startsWith('[')) {
path += sub;
} else {
path += '.' + sub;
}
}
}
return path;
}
// ============================================================================
// Data Access
// ============================================================================
/**
* @zh 根据路径获取数据
* @en Get data by path
*
* @example
* const data = { waypoints: [{ position: { x: 10, y: 20 } }] };
* getByPath(data, "waypoints[0].position.x") // => 10
*/
export function getByPath(data: unknown, path: string): unknown {
if (!path) return data;
const parts = parsePath(path);
let current: unknown = data;
for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}
switch (part.type) {
case 'property':
if (typeof current === 'object' && current !== null) {
current = (current as Record<string, unknown>)[part.name!];
} else {
return undefined;
}
break;
case 'index':
if (Array.isArray(current)) {
current = current[part.index!];
} else {
return undefined;
}
break;
case 'wildcard':
// Wildcard returns array of all values
if (Array.isArray(current)) {
return current;
}
return undefined;
}
}
return current;
}
/**
* @zh 根据路径设置数据
* @en Set data by path
*
* @example
* const data = { waypoints: [{ position: { x: 0, y: 0 } }] };
* setByPath(data, "waypoints[0].position.x", 100);
* // data.waypoints[0].position.x === 100
*/
export function setByPath(data: unknown, path: string, value: unknown): boolean {
if (!path) return false;
const parts = parsePath(path);
let current: unknown = data;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (current === null || current === undefined) {
return false;
}
switch (part.type) {
case 'property':
if (typeof current === 'object' && current !== null) {
current = (current as Record<string, unknown>)[part.name!];
} else {
return false;
}
break;
case 'index':
if (Array.isArray(current)) {
current = current[part.index!];
} else {
return false;
}
break;
case 'wildcard':
// Cannot set on wildcard
return false;
}
}
// Set the final value
const lastPart = parts[parts.length - 1];
if (current === null || current === undefined) {
return false;
}
switch (lastPart.type) {
case 'property':
if (typeof current === 'object' && current !== null) {
(current as Record<string, unknown>)[lastPart.name!] = value;
return true;
}
break;
case 'index':
if (Array.isArray(current)) {
current[lastPart.index!] = value;
return true;
}
break;
}
return false;
}
/**
* @zh 检查路径是否存在
* @en Check if path exists
*/
export function hasPath(data: unknown, path: string): boolean {
return getByPath(data, path) !== undefined;
}
/**
* @zh 删除路径上的数据
* @en Delete data at path
*/
export function deleteByPath(data: unknown, path: string): boolean {
if (!path) return false;
const parts = parsePath(path);
let current: unknown = data;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (current === null || current === undefined) {
return false;
}
switch (part.type) {
case 'property':
if (typeof current === 'object' && current !== null) {
current = (current as Record<string, unknown>)[part.name!];
} else {
return false;
}
break;
case 'index':
if (Array.isArray(current)) {
current = current[part.index!];
} else {
return false;
}
break;
default:
return false;
}
}
const lastPart = parts[parts.length - 1];
if (current === null || current === undefined) {
return false;
}
switch (lastPart.type) {
case 'property':
if (typeof current === 'object' && current !== null) {
delete (current as Record<string, unknown>)[lastPart.name!];
return true;
}
break;
case 'index':
if (Array.isArray(current)) {
current.splice(lastPart.index!, 1);
return true;
}
break;
}
return false;
}
// ============================================================================
// Array Operations
// ============================================================================
/**
* @zh 数组操作类型
* @en Array operation type
*/
export type ArrayOperation = 'insert' | 'remove' | 'move';
/**
* @zh 当数组元素变化时,更新路径中的索引
* @en Update indices in path when array elements change
*
* @param path - Original path (原始路径)
* @param arrayPath - Array base path (数组基础路径)
* @param operation - Operation type (操作类型)
* @param index - Target index (目标索引)
* @param toIndex - Move destination (移动目标,仅 move 操作)
* @returns Updated path or empty string if path becomes invalid (更新后的路径,如果路径失效则返回空字符串)
*/
export function updatePathOnArrayChange(
path: string,
arrayPath: string,
operation: ArrayOperation,
index: number,
toIndex?: number
): string {
// Check if path starts with arrayPath[
if (!path.startsWith(arrayPath + '[')) {
return path;
}
const address = parsePortPath(path);
if (address.indices.length === 0) {
return path;
}
const currentIndex = address.indices[0];
switch (operation) {
case 'insert':
if (currentIndex >= index) {
address.indices[0]++;
}
break;
case 'remove':
if (currentIndex === index) {
return ''; // Path becomes invalid
}
if (currentIndex > index) {
address.indices[0]--;
}
break;
case 'move':
if (toIndex !== undefined) {
if (currentIndex === index) {
address.indices[0] = toIndex;
} else if (index < toIndex) {
// Moving down
if (currentIndex > index && currentIndex <= toIndex) {
address.indices[0]--;
}
} else {
// Moving up
if (currentIndex >= toIndex && currentIndex < index) {
address.indices[0]++;
}
}
}
break;
}
return buildPortPath(address);
}
/**
* @zh 展开带通配符的路径
* @en Expand path with wildcards
*
* @example
* const data = { items: [{ x: 1 }, { x: 2 }, { x: 3 }] };
* expandWildcardPath("items[*].x", data)
* // => ["items[0].x", "items[1].x", "items[2].x"]
*/
export function expandWildcardPath(path: string, data: unknown): string[] {
const parts = parsePath(path);
const results: string[] = [];
function expand(currentParts: PathPart[], currentData: unknown, index: number): void {
if (index >= parts.length) {
results.push(buildPath(currentParts));
return;
}
const part = parts[index];
if (part.type === 'wildcard') {
if (Array.isArray(currentData)) {
for (let i = 0; i < currentData.length; i++) {
const newParts = [...currentParts, { type: 'index' as const, index: i }];
expand(newParts, currentData[i], index + 1);
}
}
} else {
const newParts = [...currentParts, part];
let nextData: unknown;
if (part.type === 'property' && typeof currentData === 'object' && currentData !== null) {
nextData = (currentData as Record<string, unknown>)[part.name!];
} else if (part.type === 'index' && Array.isArray(currentData)) {
nextData = currentData[part.index!];
}
expand(newParts, nextData, index + 1);
}
}
expand([], data, 0);
return results;
}
/**
* @zh 检查路径是否包含通配符
* @en Check if path contains wildcards
*/
export function hasWildcard(path: string): boolean {
return path.includes('[*]');
}
/**
* @zh 获取路径的父路径
* @en Get parent path
*
* @example
* getParentPath("items[0].position.x") // => "items[0].position"
* getParentPath("items[0]") // => "items"
* getParentPath("items") // => ""
*/
export function getParentPath(path: string): string {
const parts = parsePath(path);
if (parts.length <= 1) {
return '';
}
return buildPath(parts.slice(0, -1));
}
/**
* @zh 获取路径的最后一部分名称
* @en Get the last part name of path
*
* @example
* getPathLastName("items[0].position.x") // => "x"
* getPathLastName("items[0]") // => "[0]"
*/
export function getPathLastName(path: string): string {
const parts = parsePath(path);
if (parts.length === 0) {
return '';
}
const last = parts[parts.length - 1];
if (last.type === 'property') {
return last.name!;
} else if (last.type === 'index') {
return `[${last.index}]`;
} else {
return '[*]';
}
}

View File

@@ -0,0 +1,611 @@
/**
* @zh 蓝图属性 Schema 系统
* @en Blueprint Property Schema System
*
* @zh 提供递归类型定义,支持原始类型、数组、对象、枚举等复杂数据结构
* @en Provides recursive type definitions supporting primitives, arrays, objects, enums, etc.
*/
import { BlueprintPinType } from './pins';
// ============================================================================
// Schema Types
// ============================================================================
/**
* @zh 属性 Schema - 递归定义数据结构
* @en Property Schema - recursive data structure definition
*/
export type PropertySchema =
| PrimitiveSchema
| ArraySchema
| ObjectSchema
| EnumSchema
| RefSchema;
/**
* @zh 原始类型 Schema
* @en Primitive type schema
*/
export interface PrimitiveSchema {
type: 'primitive';
primitive: BlueprintPinType;
defaultValue?: unknown;
// Constraints | 约束
min?: number;
max?: number;
step?: number;
multiline?: boolean;
placeholder?: string;
}
/**
* @zh 数组类型 Schema
* @en Array type schema
*/
export interface ArraySchema {
type: 'array';
items: PropertySchema;
defaultValue?: unknown[];
// Constraints | 约束
minItems?: number;
maxItems?: number;
// UI Behavior | UI 行为
reorderable?: boolean;
collapsible?: boolean;
defaultCollapsed?: boolean;
itemLabel?: string;
}
/**
* @zh 对象类型 Schema
* @en Object type schema
*/
export interface ObjectSchema {
type: 'object';
properties: Record<string, PropertySchema>;
required?: string[];
// UI Behavior | UI 行为
collapsible?: boolean;
defaultCollapsed?: boolean;
displayName?: string;
}
/**
* @zh 枚举类型 Schema
* @en Enum type schema
*/
export interface EnumSchema {
type: 'enum';
options: EnumOption[];
defaultValue?: string | number;
}
/**
* @zh 枚举选项
* @en Enum option
*/
export interface EnumOption {
value: string | number;
label: string;
description?: string;
icon?: string;
}
/**
* @zh 引用类型 Schema
* @en Reference type schema
*
* @zh 引用 SchemaRegistry 中已注册的 Schema
* @en References a schema registered in SchemaRegistry
*/
export interface RefSchema {
type: 'ref';
ref: string;
}
// ============================================================================
// Schema Registry
// ============================================================================
/**
* @zh Schema 注册表
* @en Schema Registry
*
* @zh 用于注册和复用常用的 Schema 定义
* @en Used to register and reuse common Schema definitions
*/
export class SchemaRegistry {
private static schemas = new Map<string, PropertySchema>();
/**
* @zh 注册 Schema
* @en Register a schema
*/
static register(id: string, schema: PropertySchema): void {
this.schemas.set(id, schema);
}
/**
* @zh 获取 Schema
* @en Get a schema
*/
static get(id: string): PropertySchema | undefined {
return this.schemas.get(id);
}
/**
* @zh 解析引用,返回实际 Schema
* @en Resolve reference, return actual schema
*/
static resolve(schema: PropertySchema): PropertySchema {
if (schema.type === 'ref') {
const resolved = this.schemas.get(schema.ref);
if (!resolved) {
console.warn(`[SchemaRegistry] Schema not found: ${schema.ref}`);
return { type: 'primitive', primitive: 'any' };
}
return this.resolve(resolved);
}
return schema;
}
/**
* @zh 检查 Schema 是否已注册
* @en Check if schema is registered
*/
static has(id: string): boolean {
return this.schemas.has(id);
}
/**
* @zh 获取所有已注册的 Schema ID
* @en Get all registered schema IDs
*/
static keys(): string[] {
return Array.from(this.schemas.keys());
}
/**
* @zh 清空注册表
* @en Clear registry
*/
static clear(): void {
this.schemas.clear();
}
}
// ============================================================================
// Schema Utilities
// ============================================================================
/**
* @zh 获取 Schema 的默认值
* @en Get default value for a schema
*/
export function getSchemaDefaultValue(schema: PropertySchema): unknown {
const resolved = SchemaRegistry.resolve(schema);
switch (resolved.type) {
case 'primitive':
if (resolved.defaultValue !== undefined) return resolved.defaultValue;
return getPrimitiveDefaultValue(resolved.primitive);
case 'array':
if (resolved.defaultValue !== undefined) return [...resolved.defaultValue];
return [];
case 'object': {
const obj: Record<string, unknown> = {};
for (const [key, propSchema] of Object.entries(resolved.properties)) {
obj[key] = getSchemaDefaultValue(propSchema);
}
return obj;
}
case 'enum':
if (resolved.defaultValue !== undefined) return resolved.defaultValue;
return resolved.options[0]?.value;
default:
return undefined;
}
}
/**
* @zh 获取原始类型的默认值
* @en Get default value for primitive type
*/
export function getPrimitiveDefaultValue(primitive: BlueprintPinType): unknown {
switch (primitive) {
case 'bool': return false;
case 'int': return 0;
case 'float': return 0.0;
case 'string': return '';
case 'vector2': return { x: 0, y: 0 };
case 'vector3': return { x: 0, y: 0, z: 0 };
case 'color': return { r: 255, g: 255, b: 255, a: 255 };
case 'entity': return null;
case 'component': return null;
case 'object': return null;
case 'array': return [];
case 'any': return null;
case 'exec': return undefined;
default: return null;
}
}
/**
* @zh 根据 Schema 获取对应的 PinType
* @en Get corresponding PinType from Schema
*/
export function schemaToPinType(schema: PropertySchema): BlueprintPinType {
const resolved = SchemaRegistry.resolve(schema);
switch (resolved.type) {
case 'primitive':
return resolved.primitive;
case 'array':
return 'array';
case 'object':
return 'object';
case 'enum':
return typeof resolved.options[0]?.value === 'number' ? 'int' : 'string';
default:
return 'any';
}
}
/**
* @zh 验证数据是否符合 Schema
* @en Validate data against schema
*/
export function validateSchema(
schema: PropertySchema,
data: unknown,
path: string = ''
): ValidationResult {
const resolved = SchemaRegistry.resolve(schema);
const errors: ValidationError[] = [];
switch (resolved.type) {
case 'primitive':
validatePrimitive(resolved, data, path, errors);
break;
case 'array':
validateArray(resolved, data, path, errors);
break;
case 'object':
validateObject(resolved, data, path, errors);
break;
case 'enum':
validateEnum(resolved, data, path, errors);
break;
}
return {
valid: errors.length === 0,
errors
};
}
/**
* @zh 验证结果
* @en Validation result
*/
export interface ValidationResult {
valid: boolean;
errors: ValidationError[];
}
/**
* @zh 验证错误
* @en Validation error
*/
export interface ValidationError {
path: string;
message: string;
expected?: string;
received?: string;
}
function validatePrimitive(
schema: PrimitiveSchema,
data: unknown,
path: string,
errors: ValidationError[]
): void {
if (data === null || data === undefined) {
return; // Allow null/undefined for optional fields
}
const expectedType = getPrimitiveJsType(schema.primitive);
const actualType = typeof data;
if (expectedType === 'object') {
if (typeof data !== 'object') {
errors.push({
path,
message: `Expected ${schema.primitive}, got ${actualType}`,
expected: schema.primitive,
received: actualType
});
}
} else if (expectedType !== 'any' && actualType !== expectedType) {
errors.push({
path,
message: `Expected ${expectedType}, got ${actualType}`,
expected: expectedType,
received: actualType
});
}
// Numeric constraints
if ((schema.primitive === 'int' || schema.primitive === 'float') && typeof data === 'number') {
if (schema.min !== undefined && data < schema.min) {
errors.push({
path,
message: `Value ${data} is less than minimum ${schema.min}`
});
}
if (schema.max !== undefined && data > schema.max) {
errors.push({
path,
message: `Value ${data} is greater than maximum ${schema.max}`
});
}
}
}
function validateArray(
schema: ArraySchema,
data: unknown,
path: string,
errors: ValidationError[]
): void {
if (!Array.isArray(data)) {
errors.push({
path,
message: `Expected array, got ${typeof data}`,
expected: 'array',
received: typeof data
});
return;
}
if (schema.minItems !== undefined && data.length < schema.minItems) {
errors.push({
path,
message: `Array has ${data.length} items, minimum is ${schema.minItems}`
});
}
if (schema.maxItems !== undefined && data.length > schema.maxItems) {
errors.push({
path,
message: `Array has ${data.length} items, maximum is ${schema.maxItems}`
});
}
// Validate each item
for (let i = 0; i < data.length; i++) {
const itemResult = validateSchema(schema.items, data[i], `${path}[${i}]`);
errors.push(...itemResult.errors);
}
}
function validateObject(
schema: ObjectSchema,
data: unknown,
path: string,
errors: ValidationError[]
): void {
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
errors.push({
path,
message: `Expected object, got ${Array.isArray(data) ? 'array' : typeof data}`,
expected: 'object',
received: Array.isArray(data) ? 'array' : typeof data
});
return;
}
const obj = data as Record<string, unknown>;
// Check required fields
if (schema.required) {
for (const key of schema.required) {
if (!(key in obj)) {
errors.push({
path: path ? `${path}.${key}` : key,
message: `Missing required field: ${key}`
});
}
}
}
// Validate each property
for (const [key, propSchema] of Object.entries(schema.properties)) {
if (key in obj) {
const propPath = path ? `${path}.${key}` : key;
const propResult = validateSchema(propSchema, obj[key], propPath);
errors.push(...propResult.errors);
}
}
}
function validateEnum(
schema: EnumSchema,
data: unknown,
path: string,
errors: ValidationError[]
): void {
if (data === null || data === undefined) {
return;
}
const validValues = schema.options.map(o => o.value);
if (!validValues.includes(data as string | number)) {
errors.push({
path,
message: `Invalid enum value: ${data}`,
expected: validValues.join(' | '),
received: String(data)
});
}
}
function getPrimitiveJsType(primitive: BlueprintPinType): string {
switch (primitive) {
case 'bool': return 'boolean';
case 'int':
case 'float': return 'number';
case 'string': return 'string';
case 'vector2':
case 'vector3':
case 'color':
case 'entity':
case 'component':
case 'object':
case 'array': return 'object';
case 'any': return 'any';
case 'exec': return 'undefined';
default: return 'any';
}
}
/**
* @zh 深度克隆 Schema
* @en Deep clone schema
*/
export function cloneSchema(schema: PropertySchema): PropertySchema {
return JSON.parse(JSON.stringify(schema));
}
/**
* @zh 合并两个 ObjectSchema
* @en Merge two ObjectSchemas
*/
export function mergeObjectSchemas(
base: ObjectSchema,
override: Partial<ObjectSchema>
): ObjectSchema {
return {
...base,
...override,
properties: {
...base.properties,
...(override.properties || {})
},
required: [
...(base.required || []),
...(override.required || [])
]
};
}
// ============================================================================
// Schema Builder (Fluent API)
// ============================================================================
/**
* @zh Schema 构建器
* @en Schema Builder
*
* @example
* ```typescript
* const waypointSchema = Schema.object({
* position: Schema.vector2(),
* waitTime: Schema.float({ min: 0, defaultValue: 1.0 }),
* action: Schema.enum([
* { value: 'idle', label: 'Idle' },
* { value: 'patrol', label: 'Patrol' }
* ])
* });
*
* const pathSchema = Schema.array(waypointSchema, {
* minItems: 2,
* reorderable: true,
* itemLabel: 'Point {index1}'
* });
* ```
*/
export const Schema = {
// Primitives
bool(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'bool', ...options };
},
int(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'int', ...options };
},
float(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'float', ...options };
},
string(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'string', ...options };
},
vector2(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'vector2', ...options };
},
vector3(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'vector3', ...options };
},
color(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'color', ...options };
},
entity(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'entity', ...options };
},
component(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'component', ...options };
},
object_ref(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'object', ...options };
},
any(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'any', ...options };
},
// Complex types
array(
items: PropertySchema,
options?: Partial<Omit<ArraySchema, 'type' | 'items'>>
): ArraySchema {
return { type: 'array', items, ...options };
},
object(
properties: Record<string, PropertySchema>,
options?: Partial<Omit<ObjectSchema, 'type' | 'properties'>>
): ObjectSchema {
return { type: 'object', properties, ...options };
},
enum(
options: EnumOption[],
extra?: Partial<Omit<EnumSchema, 'type' | 'options'>>
): EnumSchema {
return { type: 'enum', options, ...extra };
},
ref(id: string): RefSchema {
return { type: 'ref', ref: id };
}
};

View File

@@ -1,5 +1,19 @@
# @esengine/fsm
## 9.0.0
### Patch Changes
- Updated dependencies [[`4e66bd8`](https://github.com/esengine/esengine/commit/4e66bd8e2be80b366a7723dcc48b99df0457aed4)]:
- @esengine/blueprint@4.5.0
## 8.0.0
### Patch Changes
- Updated dependencies [[`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b)]:
- @esengine/blueprint@4.4.0
## 7.0.0
### Patch Changes

View File

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

View File

@@ -0,0 +1,42 @@
# @esengine/ecs-framework-math
## 2.10.1
### Patch Changes
- Updated dependencies [[`4e66bd8`](https://github.com/esengine/esengine/commit/4e66bd8e2be80b366a7723dcc48b99df0457aed4)]:
- @esengine/blueprint@4.5.0
## 2.10.0
### Minor Changes
- [#444](https://github.com/esengine/esengine/pull/444) [`fa593a3`](https://github.com/esengine/esengine/commit/fa593a3c69292207800750f8106f418465cb7c0f) Thanks [@esengine](https://github.com/esengine)! - feat(math): add blueprint nodes for math library
- Add Vector2 blueprint nodes (Make, Break, Add, Sub, Mul, Length, Normalize, Dot, Cross, Distance, Lerp, Rotate, FromAngle)
- Add Fixed32 blueprint nodes (FromFloat, FromInt, ToFloat, ToInt, arithmetic operations, Abs, Sqrt, Floor, Ceil, Round, Sign, Min, Max, Clamp, Lerp)
- Add FixedVector2 blueprint nodes (Make, Break, Add, Sub, Mul, Negate, Length, Normalize, Dot, Cross, Distance, Lerp)
- Add Color blueprint nodes (Make, Break, FromHex, ToHex, FromHSL, ToHSL, Lerp, Lighten, Darken, Saturate, Desaturate, Invert, Grayscale, Luminance, constants)
- Add documentation for math blueprint nodes (Chinese and English)
## 2.9.0
### Minor Changes
- [#442](https://github.com/esengine/esengine/pull/442) [`bffe90b`](https://github.com/esengine/esengine/commit/bffe90b6a17563cc90709faf339b229dc3abd22d) Thanks [@esengine](https://github.com/esengine)! - feat(math): add blueprint nodes for math library
- Add Vector2 blueprint nodes (Make, Break, Add, Sub, Mul, Length, Normalize, Dot, Cross, Distance, Lerp, Rotate, FromAngle)
- Add Fixed32 blueprint nodes (FromFloat, FromInt, ToFloat, ToInt, arithmetic operations, Abs, Sqrt, Floor, Ceil, Round, Sign, Min, Max, Clamp, Lerp)
- Add FixedVector2 blueprint nodes (Make, Break, Add, Sub, Mul, Negate, Length, Normalize, Dot, Cross, Distance, Lerp)
- Add Color blueprint nodes (Make, Break, FromHex, ToHex, FromHSL, ToHSL, Lerp, Lighten, Darken, Saturate, Desaturate, Invert, Grayscale, Luminance, constants)
- Add documentation for math blueprint nodes (Chinese and English)
## 2.8.0
### Minor Changes
- [#440](https://github.com/esengine/esengine/pull/440) [`30173f0`](https://github.com/esengine/esengine/commit/30173f076415c9770a429b236b8bab95a2fdc498) Thanks [@esengine](https://github.com/esengine)! - feat(math): 添加定点数数学库 | Add fixed-point math library
**@esengine/ecs-framework-math** - 新增定点数支持 | Add fixed-point number support
- 新增 `Fixed32`Q16.16 定点数实现 | Add `Fixed32` class: Q16.16 fixed-point implementation
- 新增 `FixedMath` 工具类:定点数数学运算 | Add `FixedMath` utility: fixed-point math operations
- 新增 `FixedVector2` 类:定点数二维向量 | Add `FixedVector2` class: fixed-point 2D vector
- 支持基本算术运算、三角函数、向量运算 | Support basic arithmetic, trigonometry, vector operations

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/ecs-framework-math",
"version": "1.0.5",
"version": "2.10.1",
"description": "ECS框架2D数学库 - 提供向量、矩阵、几何形状和碰撞检测功能",
"main": "bin/index.js",
"types": "bin/index.d.ts",
@@ -40,6 +40,9 @@
},
"author": "yhh",
"license": "MIT",
"dependencies": {
"@esengine/blueprint": "workspace:*"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^28.0.3",
"@rollup/plugin-node-resolve": "^16.0.1",

View File

@@ -0,0 +1,450 @@
/**
* @zh Q16.16 定点数,用于确定性计算(帧同步)
* @en Q16.16 fixed-point number for deterministic calculations (lockstep)
*
* @zh 使用 16 位整数部分 + 16 位小数部分,范围 ±32767.99998
* @en Uses 16-bit integer + 16-bit fraction, range ±32767.99998
*
* @example
* ```typescript
* const a = Fixed32.from(3.14);
* const b = Fixed32.from(2);
* const c = a.mul(b); // 6.28
* console.log(c.toNumber());
* ```
*/
export class Fixed32 {
/**
* @zh 内部原始值32位整数
* @en Internal raw value (32-bit integer)
*/
readonly raw: number;
/**
* @zh 小数位数
* @en Fraction bits
*/
static readonly FRACTION_BITS = 16;
/**
* @zh 缩放因子 (2^16 = 65536)
* @en Scale factor (2^16 = 65536)
*/
static readonly SCALE = 65536;
/**
* @zh 最大值 (约 32767.99998)
* @en Maximum value (approximately 32767.99998)
*/
static readonly MAX_VALUE = 0x7FFFFFFF;
/**
* @zh 最小值 (约 -32768)
* @en Minimum value (approximately -32768)
*/
static readonly MIN_VALUE = -0x80000000;
/**
* @zh 精度 (1/65536 ≈ 0.0000153)
* @en Precision (1/65536 ≈ 0.0000153)
*/
static readonly EPSILON = 1;
// ==================== 常量 ====================
/** @zh 零 @en Zero */
static readonly ZERO = new Fixed32(0);
/** @zh 一 @en One */
static readonly ONE = new Fixed32(Fixed32.SCALE);
/** @zh 负一 @en Negative one */
static readonly NEG_ONE = new Fixed32(-Fixed32.SCALE);
/** @zh 二分之一 @en One half */
static readonly HALF = new Fixed32(Fixed32.SCALE >> 1);
/** @zh 圆周率 π @en Pi */
static readonly PI = new Fixed32(205887); // π * 65536
/** @zh 2π @en Two Pi */
static readonly TWO_PI = new Fixed32(411775); // 2π * 65536
/** @zh π/2 @en Pi divided by 2 */
static readonly HALF_PI = new Fixed32(102944); // π/2 * 65536
/** @zh 弧度转角度系数 (180/π) @en Radians to degrees factor */
static readonly RAD_TO_DEG = new Fixed32(3754936); // (180/π) * 65536
/** @zh 角度转弧度系数 (π/180) @en Degrees to radians factor */
static readonly DEG_TO_RAD = new Fixed32(1144); // (π/180) * 65536
// ==================== 构造 ====================
/**
* @zh 私有构造函数,使用静态方法创建实例
* @en Private constructor, use static methods to create instances
*/
private constructor(raw: number) {
// 确保是 32 位有符号整数
this.raw = raw | 0;
}
/**
* @zh 从浮点数创建定点数
* @en Create fixed-point from floating-point number
* @param n - @zh 浮点数值 @en Floating-point value
*/
static from(n: number): Fixed32 {
return new Fixed32(Math.round(n * Fixed32.SCALE));
}
/**
* @zh 从原始整数值创建定点数
* @en Create fixed-point from raw integer value
* @param raw - @zh 原始值 @en Raw value
*/
static fromRaw(raw: number): Fixed32 {
return new Fixed32(raw);
}
/**
* @zh 从整数创建定点数(无精度损失)
* @en Create fixed-point from integer (no precision loss)
* @param n - @zh 整数值 @en Integer value
*/
static fromInt(n: number): Fixed32 {
return new Fixed32((n | 0) << Fixed32.FRACTION_BITS);
}
// ==================== 转换 ====================
/**
* @zh 转换为浮点数
* @en Convert to floating-point number
*/
toNumber(): number {
return this.raw / Fixed32.SCALE;
}
/**
* @zh 获取原始整数值
* @en Get raw integer value
*/
toRaw(): number {
return this.raw;
}
/**
* @zh 转换为整数(向下取整)
* @en Convert to integer (floor)
*/
toInt(): number {
return this.raw >> Fixed32.FRACTION_BITS;
}
/**
* @zh 转换为字符串
* @en Convert to string
*/
toString(): string {
return `Fixed32(${this.toNumber().toFixed(5)})`;
}
// ==================== 基础运算 ====================
/**
* @zh 加法
* @en Addition
*/
add(other: Fixed32): Fixed32 {
return new Fixed32(this.raw + other.raw);
}
/**
* @zh 减法
* @en Subtraction
*/
sub(other: Fixed32): Fixed32 {
return new Fixed32(this.raw - other.raw);
}
/**
* @zh 乘法(使用 64 位中间结果防止溢出)
* @en Multiplication (uses 64-bit intermediate to prevent overflow)
*/
mul(other: Fixed32): Fixed32 {
// 拆分为高低 16 位进行乘法,避免溢出
const a = this.raw;
const b = other.raw;
// 使用 BigInt 确保精度JS 数字在大数时会丢失精度)
// 或者使用拆分法
const aLow = a & 0xFFFF;
const aHigh = a >> 16;
const bLow = b & 0xFFFF;
const bHigh = b >> 16;
// (aHigh * 2^16 + aLow) * (bHigh * 2^16 + bLow) / 2^16
// = aHigh * bHigh * 2^16 + aHigh * bLow + aLow * bHigh + aLow * bLow / 2^16
const lowLow = (aLow * bLow) >>> 16;
const lowHigh = aLow * bHigh;
const highLow = aHigh * bLow;
const highHigh = aHigh * bHigh;
const result = highHigh * Fixed32.SCALE + lowHigh + highLow + lowLow;
return new Fixed32(result | 0);
}
/**
* @zh 除法
* @en Division
* @throws @zh 除数为零时抛出错误 @en Throws when dividing by zero
*/
div(other: Fixed32): Fixed32 {
if (other.raw === 0) {
throw new Error('Fixed32: Division by zero');
}
// 先左移再除,保持精度
const result = ((this.raw * Fixed32.SCALE) / other.raw) | 0;
return new Fixed32(result);
}
/**
* @zh 取模运算
* @en Modulo operation
*/
mod(other: Fixed32): Fixed32 {
return new Fixed32(this.raw % other.raw);
}
/**
* @zh 取反
* @en Negation
*/
neg(): Fixed32 {
return new Fixed32(-this.raw);
}
/**
* @zh 绝对值
* @en Absolute value
*/
abs(): Fixed32 {
return this.raw >= 0 ? this : new Fixed32(-this.raw);
}
// ==================== 比较运算 ====================
/**
* @zh 等于
* @en Equal to
*/
eq(other: Fixed32): boolean {
return this.raw === other.raw;
}
/**
* @zh 不等于
* @en Not equal to
*/
ne(other: Fixed32): boolean {
return this.raw !== other.raw;
}
/**
* @zh 小于
* @en Less than
*/
lt(other: Fixed32): boolean {
return this.raw < other.raw;
}
/**
* @zh 小于等于
* @en Less than or equal to
*/
le(other: Fixed32): boolean {
return this.raw <= other.raw;
}
/**
* @zh 大于
* @en Greater than
*/
gt(other: Fixed32): boolean {
return this.raw > other.raw;
}
/**
* @zh 大于等于
* @en Greater than or equal to
*/
ge(other: Fixed32): boolean {
return this.raw >= other.raw;
}
/**
* @zh 检查是否为零
* @en Check if zero
*/
isZero(): boolean {
return this.raw === 0;
}
/**
* @zh 检查是否为正数
* @en Check if positive
*/
isPositive(): boolean {
return this.raw > 0;
}
/**
* @zh 检查是否为负数
* @en Check if negative
*/
isNegative(): boolean {
return this.raw < 0;
}
// ==================== 数学函数 ====================
/**
* @zh 平方根(牛顿迭代法,确定性)
* @en Square root (Newton's method, deterministic)
*/
static sqrt(x: Fixed32): Fixed32 {
if (x.raw <= 0) return Fixed32.ZERO;
// 牛顿迭代法
let guess = x.raw;
let prev = 0;
// 固定迭代次数确保确定性
for (let i = 0; i < 16; i++) {
prev = guess;
guess = ((guess + ((x.raw * Fixed32.SCALE) / guess) | 0) >> 1) | 0;
if (guess === prev) break;
}
return new Fixed32(guess);
}
/**
* @zh 向下取整
* @en Floor
*/
static floor(x: Fixed32): Fixed32 {
return new Fixed32(x.raw & ~(Fixed32.SCALE - 1));
}
/**
* @zh 向上取整
* @en Ceiling
*/
static ceil(x: Fixed32): Fixed32 {
const frac = x.raw & (Fixed32.SCALE - 1);
if (frac === 0) return x;
return new Fixed32((x.raw & ~(Fixed32.SCALE - 1)) + Fixed32.SCALE);
}
/**
* @zh 四舍五入
* @en Round
*/
static round(x: Fixed32): Fixed32 {
return new Fixed32((x.raw + (Fixed32.SCALE >> 1)) & ~(Fixed32.SCALE - 1));
}
/**
* @zh 最小值
* @en Minimum
*/
static min(a: Fixed32, b: Fixed32): Fixed32 {
return a.raw < b.raw ? a : b;
}
/**
* @zh 最大值
* @en Maximum
*/
static max(a: Fixed32, b: Fixed32): Fixed32 {
return a.raw > b.raw ? a : b;
}
/**
* @zh 限制范围
* @en Clamp to range
*/
static clamp(x: Fixed32, min: Fixed32, max: Fixed32): Fixed32 {
if (x.raw < min.raw) return min;
if (x.raw > max.raw) return max;
return x;
}
/**
* @zh 线性插值
* @en Linear interpolation
* @param a - @zh 起始值 @en Start value
* @param b - @zh 结束值 @en End value
* @param t - @zh 插值参数 (0-1) @en Interpolation parameter (0-1)
*/
static lerp(a: Fixed32, b: Fixed32, t: Fixed32): Fixed32 {
// a + (b - a) * t
return a.add(b.sub(a).mul(t));
}
/**
* @zh 符号函数
* @en Sign function
* @returns @zh -1, 0, 或 1 @en -1, 0, or 1
*/
static sign(x: Fixed32): Fixed32 {
if (x.raw > 0) return Fixed32.ONE;
if (x.raw < 0) return Fixed32.NEG_ONE;
return Fixed32.ZERO;
}
// ==================== 静态运算(便捷方法) ====================
/**
* @zh 加法(静态)
* @en Addition (static)
*/
static add(a: Fixed32, b: Fixed32): Fixed32 {
return a.add(b);
}
/**
* @zh 减法(静态)
* @en Subtraction (static)
*/
static sub(a: Fixed32, b: Fixed32): Fixed32 {
return a.sub(b);
}
/**
* @zh 乘法(静态)
* @en Multiplication (static)
*/
static mul(a: Fixed32, b: Fixed32): Fixed32 {
return a.mul(b);
}
/**
* @zh 除法(静态)
* @en Division (static)
*/
static div(a: Fixed32, b: Fixed32): Fixed32 {
return a.div(b);
}
}
/**
* @zh Fixed32 数据接口,用于序列化
* @en Fixed32 data interface for serialization
*/
export interface IFixed32 {
raw: number;
}

View File

@@ -0,0 +1,298 @@
import { Fixed32 } from './Fixed32';
/**
* @zh 定点数数学函数库,使用查表法确保确定性
* @en Fixed-point math functions using lookup tables for determinism
*
* @zh 所有三角函数使用预计算的查找表,确保在所有平台上结果一致
* @en All trigonometric functions use precomputed lookup tables to ensure consistent results across all platforms
*/
export class FixedMath {
/**
* @zh 正弦表大小(每 90 度的采样点数)
* @en Sine table size (samples per 90 degrees)
*/
private static readonly SIN_TABLE_SIZE = 1024;
/**
* @zh 正弦查找表0 到 90 度)
* @en Sine lookup table (0 to 90 degrees)
*/
private static readonly SIN_TABLE: Int32Array = FixedMath.generateSinTable();
/**
* @zh 生成正弦查找表
* @en Generate sine lookup table
*/
private static generateSinTable(): Int32Array {
const table = new Int32Array(FixedMath.SIN_TABLE_SIZE + 1);
for (let i = 0; i <= FixedMath.SIN_TABLE_SIZE; i++) {
const angle = (i * Math.PI) / (2 * FixedMath.SIN_TABLE_SIZE);
table[i] = Math.round(Math.sin(angle) * Fixed32.SCALE);
}
return table;
}
/**
* @zh 正弦函数(确定性)
* @en Sine function (deterministic)
* @param angle - @zh 角度(弧度,定点数) @en Angle in radians (fixed-point)
*/
static sin(angle: Fixed32): Fixed32 {
// 将角度规范化到 [0, 2π)
let raw = angle.raw % Fixed32.TWO_PI.raw;
if (raw < 0) raw += Fixed32.TWO_PI.raw;
const halfPi = Fixed32.HALF_PI.raw;
const pi = Fixed32.PI.raw;
const threeHalfPi = halfPi * 3;
let tableAngle: number;
let negative = false;
if (raw <= halfPi) {
// 第一象限: [0, π/2]
tableAngle = raw;
} else if (raw <= pi) {
// 第二象限: (π/2, π]
tableAngle = pi - raw;
} else if (raw <= threeHalfPi) {
// 第三象限: (π, 3π/2]
tableAngle = raw - pi;
negative = true;
} else {
// 第四象限: (3π/2, 2π)
tableAngle = Fixed32.TWO_PI.raw - raw;
negative = true;
}
// 计算表索引 (tableAngle 范围是 [0, π/2])
const tableIndex = Math.min(
((tableAngle * FixedMath.SIN_TABLE_SIZE) / halfPi) | 0,
FixedMath.SIN_TABLE_SIZE
);
const result = FixedMath.SIN_TABLE[tableIndex];
return Fixed32.fromRaw(negative ? -result : result);
}
/**
* @zh 余弦函数(确定性)
* @en Cosine function (deterministic)
* @param angle - @zh 角度(弧度,定点数) @en Angle in radians (fixed-point)
*/
static cos(angle: Fixed32): Fixed32 {
// cos(x) = sin(x + π/2)
return FixedMath.sin(angle.add(Fixed32.HALF_PI));
}
/**
* @zh 正切函数(确定性)
* @en Tangent function (deterministic)
* @param angle - @zh 角度(弧度,定点数) @en Angle in radians (fixed-point)
*/
static tan(angle: Fixed32): Fixed32 {
const cosVal = FixedMath.cos(angle);
if (cosVal.isZero()) {
// 返回最大值表示无穷大
return Fixed32.fromRaw(Fixed32.MAX_VALUE);
}
return FixedMath.sin(angle).div(cosVal);
}
/**
* @zh 反正切函数 atan2确定性
* @en Arc tangent of y/x (deterministic)
* @param y - @zh Y 坐标 @en Y coordinate
* @param x - @zh X 坐标 @en X coordinate
* @returns @zh 角度(弧度,范围 -π 到 π)@en Angle in radians (range -π to π)
*/
static atan2(y: Fixed32, x: Fixed32): Fixed32 {
const yRaw = y.raw;
const xRaw = x.raw;
if (xRaw === 0 && yRaw === 0) {
return Fixed32.ZERO;
}
// 使用 CORDIC 算法的简化版本
const absY = Math.abs(yRaw);
const absX = Math.abs(xRaw);
let angle: number;
if (absX >= absY) {
// |y/x| <= 1使用泰勒展开近似
angle = FixedMath.atanApprox(absY, absX);
} else {
// |y/x| > 1使用恒等式 atan(y/x) = π/2 - atan(x/y)
angle = Fixed32.HALF_PI.raw - FixedMath.atanApprox(absX, absY);
}
// 根据象限调整
if (xRaw < 0) {
angle = Fixed32.PI.raw - angle;
}
if (yRaw < 0) {
angle = -angle;
}
return Fixed32.fromRaw(angle);
}
/**
* @zh atan 近似计算(内部使用)
* @en Approximate atan calculation (internal use)
*/
private static atanApprox(num: number, den: number): number {
if (den === 0) return Fixed32.HALF_PI.raw;
// 使用多项式近似: atan(x) ≈ x - x³/3 + x⁵/5
// 对于 |x| <= 1 精度足够
const ratio = ((num * Fixed32.SCALE) / den) | 0;
// 简化的多项式: atan(x) ≈ x * (1 - x²/3)
// 更精确的版本: atan(x) ≈ x / (1 + 0.28125 * x²)
const x2 = ((ratio * ratio) / Fixed32.SCALE) | 0;
const factor = Fixed32.SCALE + ((x2 * 18432) / Fixed32.SCALE | 0); // 0.28125 * 65536 ≈ 18432
const result = ((ratio * Fixed32.SCALE) / factor) | 0;
return result;
}
/**
* @zh 反正弦函数(确定性)
* @en Arc sine function (deterministic)
* @param x - @zh 值(范围 -1 到 1@en Value (range -1 to 1)
*/
static asin(x: Fixed32): Fixed32 {
// asin(x) = atan2(x, sqrt(1 - x²))
const one = Fixed32.ONE;
const x2 = x.mul(x);
const sqrt = Fixed32.sqrt(one.sub(x2));
return FixedMath.atan2(x, sqrt);
}
/**
* @zh 反余弦函数(确定性)
* @en Arc cosine function (deterministic)
* @param x - @zh 值(范围 -1 到 1@en Value (range -1 to 1)
*/
static acos(x: Fixed32): Fixed32 {
// acos(x) = π/2 - asin(x)
return Fixed32.HALF_PI.sub(FixedMath.asin(x));
}
/**
* @zh 角度规范化到 [-π, π]
* @en Normalize angle to [-π, π]
*/
static normalizeAngle(angle: Fixed32): Fixed32 {
let raw = angle.raw % Fixed32.TWO_PI.raw;
if (raw > Fixed32.PI.raw) {
raw -= Fixed32.TWO_PI.raw;
} else if (raw < -Fixed32.PI.raw) {
raw += Fixed32.TWO_PI.raw;
}
return Fixed32.fromRaw(raw);
}
/**
* @zh 角度差值(最短路径)
* @en Angle difference (shortest path)
*/
static angleDelta(from: Fixed32, to: Fixed32): Fixed32 {
return FixedMath.normalizeAngle(to.sub(from));
}
/**
* @zh 角度线性插值(最短路径)
* @en Angle linear interpolation (shortest path)
*/
static lerpAngle(from: Fixed32, to: Fixed32, t: Fixed32): Fixed32 {
const delta = FixedMath.angleDelta(from, to);
return from.add(delta.mul(t));
}
/**
* @zh 弧度转角度
* @en Radians to degrees
*/
static radToDeg(rad: Fixed32): Fixed32 {
return rad.mul(Fixed32.RAD_TO_DEG);
}
/**
* @zh 角度转弧度
* @en Degrees to radians
*/
static degToRad(deg: Fixed32): Fixed32 {
return deg.mul(Fixed32.DEG_TO_RAD);
}
/**
* @zh 幂函数(整数次幂)
* @en Power function (integer exponent)
*/
static pow(base: Fixed32, exp: number): Fixed32 {
if (exp === 0) return Fixed32.ONE;
if (exp < 0) {
base = Fixed32.ONE.div(base);
exp = -exp;
}
let result = Fixed32.ONE;
while (exp > 0) {
if (exp & 1) {
result = result.mul(base);
}
base = base.mul(base);
exp >>= 1;
}
return result;
}
/**
* @zh 指数函数近似e^x
* @en Exponential function approximation (e^x)
*/
static exp(x: Fixed32): Fixed32 {
// 使用泰勒展开: e^x ≈ 1 + x + x²/2 + x³/6 + x⁴/24
const one = Fixed32.ONE;
const x2 = x.mul(x);
const x3 = x2.mul(x);
const x4 = x3.mul(x);
return one
.add(x)
.add(x2.div(Fixed32.from(2)))
.add(x3.div(Fixed32.from(6)))
.add(x4.div(Fixed32.from(24)));
}
/**
* @zh 自然对数近似
* @en Natural logarithm approximation
*/
static ln(x: Fixed32): Fixed32 {
if (x.raw <= 0) {
throw new Error('FixedMath.ln: argument must be positive');
}
// 使用牛顿迭代法: y_{n+1} = y_n + 2 * (x - exp(y_n)) / (x + exp(y_n))
let y = Fixed32.ZERO;
const two = Fixed32.from(2);
for (let i = 0; i < 10; i++) {
const expY = FixedMath.exp(y);
const diff = x.sub(expY);
const sum = x.add(expY);
y = y.add(two.mul(diff).div(sum));
}
return y;
}
}

View File

@@ -0,0 +1,504 @@
import { Fixed32, type IFixed32 } from './Fixed32';
import { FixedMath } from './FixedMath';
/**
* @zh 定点数 2D 向量数据接口
* @en Fixed-point 2D vector data interface
*/
export interface IFixedVector2 {
x: IFixed32;
y: IFixed32;
}
/**
* @zh 定点数 2D 向量类,用于确定性计算(帧同步)
* @en Fixed-point 2D vector class for deterministic calculations (lockstep)
*
* @zh 所有运算返回新实例,保证不可变性
* @en All operations return new instances, ensuring immutability
*
* @example
* ```typescript
* const a = FixedVector2.from(3, 4);
* const b = FixedVector2.from(1, 2);
* const c = a.add(b); // (4, 6)
* const len = a.length(); // 5
* ```
*/
export class FixedVector2 {
/** @zh X 分量 @en X component */
readonly x: Fixed32;
/** @zh Y 分量 @en Y component */
readonly y: Fixed32;
// ==================== 常量 ====================
/** @zh 零向量 (0, 0) @en Zero vector */
static readonly ZERO = new FixedVector2(Fixed32.ZERO, Fixed32.ZERO);
/** @zh 单位向量 (1, 1) @en One vector */
static readonly ONE = new FixedVector2(Fixed32.ONE, Fixed32.ONE);
/** @zh 右方向 (1, 0) @en Right direction */
static readonly RIGHT = new FixedVector2(Fixed32.ONE, Fixed32.ZERO);
/** @zh 左方向 (-1, 0) @en Left direction */
static readonly LEFT = new FixedVector2(Fixed32.NEG_ONE, Fixed32.ZERO);
/** @zh 上方向 (0, 1) @en Up direction */
static readonly UP = new FixedVector2(Fixed32.ZERO, Fixed32.ONE);
/** @zh 下方向 (0, -1) @en Down direction */
static readonly DOWN = new FixedVector2(Fixed32.ZERO, Fixed32.NEG_ONE);
// ==================== 构造 ====================
/**
* @zh 创建定点数向量
* @en Create fixed-point vector
*/
constructor(x: Fixed32, y: Fixed32) {
this.x = x;
this.y = y;
}
/**
* @zh 从浮点数创建向量
* @en Create vector from floating-point numbers
*/
static from(x: number, y: number): FixedVector2 {
return new FixedVector2(Fixed32.from(x), Fixed32.from(y));
}
/**
* @zh 从原始整数值创建向量
* @en Create vector from raw integer values
*/
static fromRaw(rawX: number, rawY: number): FixedVector2 {
return new FixedVector2(Fixed32.fromRaw(rawX), Fixed32.fromRaw(rawY));
}
/**
* @zh 从整数创建向量
* @en Create vector from integers
*/
static fromInt(x: number, y: number): FixedVector2 {
return new FixedVector2(Fixed32.fromInt(x), Fixed32.fromInt(y));
}
/**
* @zh 从普通向量接口创建
* @en Create from plain vector interface
*/
static fromObject(obj: { x: number; y: number }): FixedVector2 {
return FixedVector2.from(obj.x, obj.y);
}
// ==================== 转换 ====================
/**
* @zh 转换为浮点数对象(用于渲染)
* @en Convert to floating-point object (for rendering)
*/
toObject(): { x: number; y: number } {
return {
x: this.x.toNumber(),
y: this.y.toNumber()
};
}
/**
* @zh 转换为数组
* @en Convert to array
*/
toArray(): [number, number] {
return [this.x.toNumber(), this.y.toNumber()];
}
/**
* @zh 获取原始值对象(用于网络传输)
* @en Get raw values object (for network transmission)
*/
toRawObject(): { x: number; y: number } {
return {
x: this.x.toRaw(),
y: this.y.toRaw()
};
}
/**
* @zh 转换为字符串
* @en Convert to string
*/
toString(): string {
return `FixedVector2(${this.x.toNumber().toFixed(3)}, ${this.y.toNumber().toFixed(3)})`;
}
/**
* @zh 克隆向量
* @en Clone vector
*/
clone(): FixedVector2 {
return new FixedVector2(this.x, this.y);
}
// ==================== 基础运算 ====================
/**
* @zh 向量加法
* @en Vector addition
*/
add(other: FixedVector2): FixedVector2 {
return new FixedVector2(this.x.add(other.x), this.y.add(other.y));
}
/**
* @zh 向量减法
* @en Vector subtraction
*/
sub(other: FixedVector2): FixedVector2 {
return new FixedVector2(this.x.sub(other.x), this.y.sub(other.y));
}
/**
* @zh 标量乘法
* @en Scalar multiplication
*/
mul(scalar: Fixed32): FixedVector2 {
return new FixedVector2(this.x.mul(scalar), this.y.mul(scalar));
}
/**
* @zh 标量除法
* @en Scalar division
*/
div(scalar: Fixed32): FixedVector2 {
return new FixedVector2(this.x.div(scalar), this.y.div(scalar));
}
/**
* @zh 分量乘法
* @en Component-wise multiplication
*/
mulComponents(other: FixedVector2): FixedVector2 {
return new FixedVector2(this.x.mul(other.x), this.y.mul(other.y));
}
/**
* @zh 分量除法
* @en Component-wise division
*/
divComponents(other: FixedVector2): FixedVector2 {
return new FixedVector2(this.x.div(other.x), this.y.div(other.y));
}
/**
* @zh 取反
* @en Negate
*/
neg(): FixedVector2 {
return new FixedVector2(this.x.neg(), this.y.neg());
}
// ==================== 向量运算 ====================
/**
* @zh 点积
* @en Dot product
*/
dot(other: FixedVector2): Fixed32 {
return this.x.mul(other.x).add(this.y.mul(other.y));
}
/**
* @zh 叉积2D 返回标量)
* @en Cross product (returns scalar in 2D)
*/
cross(other: FixedVector2): Fixed32 {
return this.x.mul(other.y).sub(this.y.mul(other.x));
}
/**
* @zh 长度的平方
* @en Length squared
*/
lengthSquared(): Fixed32 {
return this.dot(this);
}
/**
* @zh 长度(模)
* @en Length (magnitude)
*/
length(): Fixed32 {
return Fixed32.sqrt(this.lengthSquared());
}
/**
* @zh 归一化(转换为单位向量)
* @en Normalize (convert to unit vector)
*/
normalize(): FixedVector2 {
const len = this.length();
if (len.isZero()) {
return FixedVector2.ZERO;
}
return this.div(len);
}
/**
* @zh 到另一个向量的距离平方
* @en Distance squared to another vector
*/
distanceSquaredTo(other: FixedVector2): Fixed32 {
const dx = this.x.sub(other.x);
const dy = this.y.sub(other.y);
return dx.mul(dx).add(dy.mul(dy));
}
/**
* @zh 到另一个向量的距离
* @en Distance to another vector
*/
distanceTo(other: FixedVector2): Fixed32 {
return Fixed32.sqrt(this.distanceSquaredTo(other));
}
/**
* @zh 获取垂直向量顺时针旋转90度
* @en Get perpendicular vector (clockwise 90 degrees)
*/
perpendicular(): FixedVector2 {
return new FixedVector2(this.y, this.x.neg());
}
/**
* @zh 获取垂直向量逆时针旋转90度
* @en Get perpendicular vector (counter-clockwise 90 degrees)
*/
perpendicularCCW(): FixedVector2 {
return new FixedVector2(this.y.neg(), this.x);
}
/**
* @zh 投影到另一个向量上
* @en Project onto another vector
*/
projectOnto(onto: FixedVector2): FixedVector2 {
const dot = this.dot(onto);
const lenSq = onto.lengthSquared();
if (lenSq.isZero()) {
return FixedVector2.ZERO;
}
return onto.mul(dot.div(lenSq));
}
/**
* @zh 反射向量(关于法线)
* @en Reflect vector (about normal)
*/
reflect(normal: FixedVector2): FixedVector2 {
const dot = this.dot(normal);
const two = Fixed32.from(2);
return this.sub(normal.mul(two.mul(dot)));
}
// ==================== 旋转和角度 ====================
/**
* @zh 旋转向量(顺时针为正,左手坐标系)
* @en Rotate vector (clockwise positive, left-hand coordinate system)
* @param angle - @zh 旋转角度(弧度)@en Rotation angle in radians
*/
rotate(angle: Fixed32): FixedVector2 {
const cos = FixedMath.cos(angle);
const sin = FixedMath.sin(angle);
// 顺时针旋转: x' = x*cos + y*sin, y' = -x*sin + y*cos
return new FixedVector2(
this.x.mul(cos).add(this.y.mul(sin)),
this.x.neg().mul(sin).add(this.y.mul(cos))
);
}
/**
* @zh 围绕一个点旋转
* @en Rotate around a point
*/
rotateAround(center: FixedVector2, angle: Fixed32): FixedVector2 {
return this.sub(center).rotate(angle).add(center);
}
/**
* @zh 获取向量角度(弧度)
* @en Get vector angle in radians
*/
angle(): Fixed32 {
return FixedMath.atan2(this.y, this.x);
}
/**
* @zh 获取与另一个向量的夹角
* @en Get angle between this and another vector
*/
angleTo(other: FixedVector2): Fixed32 {
const cross = this.cross(other);
const dot = this.dot(other);
return FixedMath.atan2(cross, dot);
}
/**
* @zh 从极坐标创建向量
* @en Create vector from polar coordinates
*/
static fromPolar(length: Fixed32, angle: Fixed32): FixedVector2 {
return new FixedVector2(
length.mul(FixedMath.cos(angle)),
length.mul(FixedMath.sin(angle))
);
}
/**
* @zh 从角度创建单位向量
* @en Create unit vector from angle
*/
static fromAngle(angle: Fixed32): FixedVector2 {
return new FixedVector2(FixedMath.cos(angle), FixedMath.sin(angle));
}
// ==================== 比较运算 ====================
/**
* @zh 检查是否相等
* @en Check equality
*/
equals(other: FixedVector2): boolean {
return this.x.eq(other.x) && this.y.eq(other.y);
}
/**
* @zh 检查是否为零向量
* @en Check if zero vector
*/
isZero(): boolean {
return this.x.isZero() && this.y.isZero();
}
// ==================== 限制和插值 ====================
/**
* @zh 限制长度
* @en Clamp length
*/
clampLength(maxLength: Fixed32): FixedVector2 {
const lenSq = this.lengthSquared();
const maxLenSq = maxLength.mul(maxLength);
if (lenSq.gt(maxLenSq)) {
return this.normalize().mul(maxLength);
}
return this;
}
/**
* @zh 限制分量范围
* @en Clamp components
*/
clamp(min: FixedVector2, max: FixedVector2): FixedVector2 {
return new FixedVector2(
Fixed32.clamp(this.x, min.x, max.x),
Fixed32.clamp(this.y, min.y, max.y)
);
}
/**
* @zh 线性插值
* @en Linear interpolation
*/
lerp(target: FixedVector2, t: Fixed32): FixedVector2 {
return new FixedVector2(
Fixed32.lerp(this.x, target.x, t),
Fixed32.lerp(this.y, target.y, t)
);
}
/**
* @zh 向目标移动固定距离
* @en Move towards target by fixed distance
*/
moveTowards(target: FixedVector2, maxDistance: Fixed32): FixedVector2 {
const diff = target.sub(this);
const dist = diff.length();
if (dist.isZero() || dist.le(maxDistance)) {
return target;
}
return this.add(diff.div(dist).mul(maxDistance));
}
// ==================== 静态方法 ====================
/**
* @zh 向量加法(静态)
* @en Vector addition (static)
*/
static add(a: FixedVector2, b: FixedVector2): FixedVector2 {
return a.add(b);
}
/**
* @zh 向量减法(静态)
* @en Vector subtraction (static)
*/
static sub(a: FixedVector2, b: FixedVector2): FixedVector2 {
return a.sub(b);
}
/**
* @zh 点积(静态)
* @en Dot product (static)
*/
static dot(a: FixedVector2, b: FixedVector2): Fixed32 {
return a.dot(b);
}
/**
* @zh 叉积(静态)
* @en Cross product (static)
*/
static cross(a: FixedVector2, b: FixedVector2): Fixed32 {
return a.cross(b);
}
/**
* @zh 距离(静态)
* @en Distance (static)
*/
static distance(a: FixedVector2, b: FixedVector2): Fixed32 {
return a.distanceTo(b);
}
/**
* @zh 线性插值(静态)
* @en Linear interpolation (static)
*/
static lerp(a: FixedVector2, b: FixedVector2, t: Fixed32): FixedVector2 {
return a.lerp(b, t);
}
/**
* @zh 获取两个向量的最小分量
* @en Get minimum components of two vectors
*/
static min(a: FixedVector2, b: FixedVector2): FixedVector2 {
return new FixedVector2(Fixed32.min(a.x, b.x), Fixed32.min(a.y, b.y));
}
/**
* @zh 获取两个向量的最大分量
* @en Get maximum components of two vectors
*/
static max(a: FixedVector2, b: FixedVector2): FixedVector2 {
return new FixedVector2(Fixed32.max(a.x, b.x), Fixed32.max(a.y, b.y));
}
}

View File

@@ -3,6 +3,7 @@
*
* 2D数学库为游戏开发提供完整的数学工具
* - 基础数学类(向量、矩阵、几何形状)
* - 定点数数学(用于帧同步确定性计算)
* - 碰撞检测算法
* - 动画插值和缓动函数
* - 数学工具函数
@@ -16,6 +17,11 @@ export { Matrix3 } from './Matrix3';
export { Rectangle } from './Rectangle';
export { Circle } from './Circle';
// 定点数数学(帧同步确定性计算)
export { Fixed32, type IFixed32 } from './Fixed32';
export { FixedVector2, type IFixedVector2 } from './FixedVector2';
export { FixedMath } from './FixedMath';
// 数学工具
export { MathUtils } from './MathUtils';
@@ -27,3 +33,6 @@ export * from './Collision';
// 动画和插值
export * from './Animation';
// 蓝图节点
export * from './nodes';

View File

@@ -0,0 +1,463 @@
/**
* @zh 颜色蓝图节点
* @en Color Blueprint Nodes
*/
import type { BlueprintNodeTemplate, BlueprintNode, INodeExecutor, ExecutionResult } from '@esengine/blueprint';
import { Color } from '../Color';
interface ColorContext {
evaluateInput(nodeId: string, pinName: string, defaultValue?: unknown): unknown;
}
// Make Color from RGBA
export const MakeColorTemplate: BlueprintNodeTemplate = {
type: 'MakeColor',
title: 'Make Color',
category: 'math',
description: 'Creates a Color from RGBA',
keywords: ['make', 'create', 'color', 'rgba'],
menuPath: ['Math', 'Color', 'Make Color'],
isPure: true,
inputs: [
{ name: 'r', displayName: 'R (0-255)', type: 'int', defaultValue: 255 },
{ name: 'g', displayName: 'G (0-255)', type: 'int', defaultValue: 255 },
{ name: 'b', displayName: 'B (0-255)', type: 'int', defaultValue: 255 },
{ name: 'a', displayName: 'A (0-1)', type: 'float', defaultValue: 1 }
],
outputs: [
{ name: 'color', displayName: 'Color', type: 'color' }
],
color: '#E91E63'
};
export class MakeColorExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as ColorContext;
const r = Number(ctx.evaluateInput(node.id, 'r', 255));
const g = Number(ctx.evaluateInput(node.id, 'g', 255));
const b = Number(ctx.evaluateInput(node.id, 'b', 255));
const a = Number(ctx.evaluateInput(node.id, 'a', 1));
return { outputs: { color: new Color(r, g, b, a) } };
}
}
// Break Color
export const BreakColorTemplate: BlueprintNodeTemplate = {
type: 'BreakColor',
title: 'Break Color',
category: 'math',
description: 'Breaks a Color into RGBA',
keywords: ['break', 'split', 'color', 'rgba'],
menuPath: ['Math', 'Color', 'Break Color'],
isPure: true,
inputs: [
{ name: 'color', displayName: 'Color', type: 'color' }
],
outputs: [
{ name: 'r', displayName: 'R', type: 'int' },
{ name: 'g', displayName: 'G', type: 'int' },
{ name: 'b', displayName: 'B', type: 'int' },
{ name: 'a', displayName: 'A', type: 'float' }
],
color: '#E91E63'
};
export class BreakColorExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as ColorContext;
const color = ctx.evaluateInput(node.id, 'color', Color.WHITE) as Color;
const c = color ?? Color.WHITE;
return { outputs: { r: c.r, g: c.g, b: c.b, a: c.a } };
}
}
// Color from Hex
export const ColorFromHexTemplate: BlueprintNodeTemplate = {
type: 'ColorFromHex',
title: 'Color From Hex',
category: 'math',
description: 'Creates a Color from hex string',
keywords: ['color', 'hex', 'from', 'create'],
menuPath: ['Math', 'Color', 'From Hex'],
isPure: true,
inputs: [
{ name: 'hex', displayName: 'Hex', type: 'string', defaultValue: '#FFFFFF' },
{ name: 'alpha', displayName: 'Alpha', type: 'float', defaultValue: 1 }
],
outputs: [
{ name: 'color', displayName: 'Color', type: 'color' }
],
color: '#E91E63'
};
export class ColorFromHexExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as ColorContext;
const hex = String(ctx.evaluateInput(node.id, 'hex', '#FFFFFF'));
const alpha = Number(ctx.evaluateInput(node.id, 'alpha', 1));
return { outputs: { color: Color.fromHex(hex, alpha) } };
}
}
// Color to Hex
export const ColorToHexTemplate: BlueprintNodeTemplate = {
type: 'ColorToHex',
title: 'Color To Hex',
category: 'math',
description: 'Converts a Color to hex string',
keywords: ['color', 'hex', 'to', 'convert'],
menuPath: ['Math', 'Color', 'To Hex'],
isPure: true,
inputs: [
{ name: 'color', displayName: 'Color', type: 'color' }
],
outputs: [
{ name: 'hex', displayName: 'Hex', type: 'string' }
],
color: '#E91E63'
};
export class ColorToHexExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as ColorContext;
const color = ctx.evaluateInput(node.id, 'color', Color.WHITE) as Color;
return { outputs: { hex: (color ?? Color.WHITE).toHex() } };
}
}
// Color from HSL
export const ColorFromHSLTemplate: BlueprintNodeTemplate = {
type: 'ColorFromHSL',
title: 'Color From HSL',
category: 'math',
description: 'Creates a Color from HSL values',
keywords: ['color', 'hsl', 'hue', 'saturation', 'lightness'],
menuPath: ['Math', 'Color', 'From HSL'],
isPure: true,
inputs: [
{ name: 'h', displayName: 'H (0-360)', type: 'float', defaultValue: 0 },
{ name: 's', displayName: 'S (0-1)', type: 'float', defaultValue: 1 },
{ name: 'l', displayName: 'L (0-1)', type: 'float', defaultValue: 0.5 },
{ name: 'a', displayName: 'A (0-1)', type: 'float', defaultValue: 1 }
],
outputs: [
{ name: 'color', displayName: 'Color', type: 'color' }
],
color: '#E91E63'
};
export class ColorFromHSLExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as ColorContext;
const h = Number(ctx.evaluateInput(node.id, 'h', 0));
const s = Number(ctx.evaluateInput(node.id, 's', 1));
const l = Number(ctx.evaluateInput(node.id, 'l', 0.5));
const a = Number(ctx.evaluateInput(node.id, 'a', 1));
return { outputs: { color: Color.fromHSL(h, s, l, a) } };
}
}
// Color to HSL
export const ColorToHSLTemplate: BlueprintNodeTemplate = {
type: 'ColorToHSL',
title: 'Color To HSL',
category: 'math',
description: 'Converts a Color to HSL values',
keywords: ['color', 'hsl', 'convert'],
menuPath: ['Math', 'Color', 'To HSL'],
isPure: true,
inputs: [
{ name: 'color', displayName: 'Color', type: 'color' }
],
outputs: [
{ name: 'h', displayName: 'H', type: 'float' },
{ name: 's', displayName: 'S', type: 'float' },
{ name: 'l', displayName: 'L', type: 'float' }
],
color: '#E91E63'
};
export class ColorToHSLExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as ColorContext;
const color = ctx.evaluateInput(node.id, 'color', Color.WHITE) as Color;
const hsl = (color ?? Color.WHITE).toHSL();
return { outputs: { h: hsl.h, s: hsl.s, l: hsl.l } };
}
}
// Color Lerp
export const ColorLerpTemplate: BlueprintNodeTemplate = {
type: 'ColorLerp',
title: 'Color Lerp',
category: 'math',
description: 'Linear interpolation between two colors',
keywords: ['color', 'lerp', 'interpolate', 'blend'],
menuPath: ['Math', 'Color', 'Lerp'],
isPure: true,
inputs: [
{ name: 'from', displayName: 'From', type: 'color' },
{ name: 'to', displayName: 'To', type: 'color' },
{ name: 't', displayName: 'T', type: 'float', defaultValue: 0.5 }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'color' }
],
color: '#E91E63'
};
export class ColorLerpExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as ColorContext;
const from = ctx.evaluateInput(node.id, 'from', Color.BLACK) as Color;
const to = ctx.evaluateInput(node.id, 'to', Color.WHITE) as Color;
const t = Number(ctx.evaluateInput(node.id, 't', 0.5));
return { outputs: { result: Color.lerp(from ?? Color.BLACK, to ?? Color.WHITE, t) } };
}
}
// Color Lighten
export const ColorLightenTemplate: BlueprintNodeTemplate = {
type: 'ColorLighten',
title: 'Color Lighten',
category: 'math',
description: 'Lightens a color',
keywords: ['color', 'lighten', 'bright'],
menuPath: ['Math', 'Color', 'Lighten'],
isPure: true,
inputs: [
{ name: 'color', displayName: 'Color', type: 'color' },
{ name: 'amount', displayName: 'Amount', type: 'float', defaultValue: 0.1 }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'color' }
],
color: '#E91E63'
};
export class ColorLightenExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as ColorContext;
const color = ctx.evaluateInput(node.id, 'color', Color.GRAY) as Color;
const amount = Number(ctx.evaluateInput(node.id, 'amount', 0.1));
return { outputs: { result: Color.lighten(color ?? Color.GRAY, amount) } };
}
}
// Color Darken
export const ColorDarkenTemplate: BlueprintNodeTemplate = {
type: 'ColorDarken',
title: 'Color Darken',
category: 'math',
description: 'Darkens a color',
keywords: ['color', 'darken', 'dark'],
menuPath: ['Math', 'Color', 'Darken'],
isPure: true,
inputs: [
{ name: 'color', displayName: 'Color', type: 'color' },
{ name: 'amount', displayName: 'Amount', type: 'float', defaultValue: 0.1 }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'color' }
],
color: '#E91E63'
};
export class ColorDarkenExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as ColorContext;
const color = ctx.evaluateInput(node.id, 'color', Color.GRAY) as Color;
const amount = Number(ctx.evaluateInput(node.id, 'amount', 0.1));
return { outputs: { result: Color.darken(color ?? Color.GRAY, amount) } };
}
}
// Color Saturate
export const ColorSaturateTemplate: BlueprintNodeTemplate = {
type: 'ColorSaturate',
title: 'Color Saturate',
category: 'math',
description: 'Increases color saturation',
keywords: ['color', 'saturate', 'saturation'],
menuPath: ['Math', 'Color', 'Saturate'],
isPure: true,
inputs: [
{ name: 'color', displayName: 'Color', type: 'color' },
{ name: 'amount', displayName: 'Amount', type: 'float', defaultValue: 0.1 }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'color' }
],
color: '#E91E63'
};
export class ColorSaturateExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as ColorContext;
const color = ctx.evaluateInput(node.id, 'color', Color.GRAY) as Color;
const amount = Number(ctx.evaluateInput(node.id, 'amount', 0.1));
return { outputs: { result: Color.saturate(color ?? Color.GRAY, amount) } };
}
}
// Color Desaturate
export const ColorDesaturateTemplate: BlueprintNodeTemplate = {
type: 'ColorDesaturate',
title: 'Color Desaturate',
category: 'math',
description: 'Decreases color saturation',
keywords: ['color', 'desaturate', 'saturation'],
menuPath: ['Math', 'Color', 'Desaturate'],
isPure: true,
inputs: [
{ name: 'color', displayName: 'Color', type: 'color' },
{ name: 'amount', displayName: 'Amount', type: 'float', defaultValue: 0.1 }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'color' }
],
color: '#E91E63'
};
export class ColorDesaturateExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as ColorContext;
const color = ctx.evaluateInput(node.id, 'color', Color.GRAY) as Color;
const amount = Number(ctx.evaluateInput(node.id, 'amount', 0.1));
return { outputs: { result: Color.desaturate(color ?? Color.GRAY, amount) } };
}
}
// Color Invert
export const ColorInvertTemplate: BlueprintNodeTemplate = {
type: 'ColorInvert',
title: 'Color Invert',
category: 'math',
description: 'Inverts a color',
keywords: ['color', 'invert', 'inverse'],
menuPath: ['Math', 'Color', 'Invert'],
isPure: true,
inputs: [
{ name: 'color', displayName: 'Color', type: 'color' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'color' }
],
color: '#E91E63'
};
export class ColorInvertExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as ColorContext;
const color = ctx.evaluateInput(node.id, 'color', Color.WHITE) as Color;
return { outputs: { result: Color.invert(color ?? Color.WHITE) } };
}
}
// Color Grayscale
export const ColorGrayscaleTemplate: BlueprintNodeTemplate = {
type: 'ColorGrayscale',
title: 'Color Grayscale',
category: 'math',
description: 'Converts color to grayscale',
keywords: ['color', 'grayscale', 'gray', 'grey'],
menuPath: ['Math', 'Color', 'Grayscale'],
isPure: true,
inputs: [
{ name: 'color', displayName: 'Color', type: 'color' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'color' }
],
color: '#E91E63'
};
export class ColorGrayscaleExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as ColorContext;
const color = ctx.evaluateInput(node.id, 'color', Color.WHITE) as Color;
return { outputs: { result: Color.grayscale(color ?? Color.WHITE) } };
}
}
// Color Luminance
export const ColorLuminanceTemplate: BlueprintNodeTemplate = {
type: 'ColorLuminance',
title: 'Color Luminance',
category: 'math',
description: 'Gets perceived brightness (0-1)',
keywords: ['color', 'luminance', 'brightness'],
menuPath: ['Math', 'Color', 'Luminance'],
isPure: true,
inputs: [
{ name: 'color', displayName: 'Color', type: 'color' }
],
outputs: [
{ name: 'luminance', displayName: 'Luminance', type: 'float' }
],
color: '#E91E63'
};
export class ColorLuminanceExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as ColorContext;
const color = ctx.evaluateInput(node.id, 'color', Color.WHITE) as Color;
return { outputs: { luminance: Color.luminance(color ?? Color.WHITE) } };
}
}
// Color Constants
export const ColorConstantsTemplate: BlueprintNodeTemplate = {
type: 'ColorConstants',
title: 'Color Constants',
category: 'math',
description: 'Common color constants',
keywords: ['color', 'constant', 'red', 'green', 'blue', 'white', 'black'],
menuPath: ['Math', 'Color', 'Constants'],
isPure: true,
inputs: [],
outputs: [
{ name: 'white', displayName: 'White', type: 'color' },
{ name: 'black', displayName: 'Black', type: 'color' },
{ name: 'red', displayName: 'Red', type: 'color' },
{ name: 'green', displayName: 'Green', type: 'color' },
{ name: 'blue', displayName: 'Blue', type: 'color' },
{ name: 'transparent', displayName: 'Transparent', type: 'color' }
],
color: '#E91E63'
};
export class ColorConstantsExecutor implements INodeExecutor {
execute(): ExecutionResult {
return {
outputs: {
white: Color.WHITE,
black: Color.BLACK,
red: Color.RED,
green: Color.GREEN,
blue: Color.BLUE,
transparent: Color.TRANSPARENT
}
};
}
}
// Node definitions collection
export const ColorNodeDefinitions = [
{ template: MakeColorTemplate, executor: new MakeColorExecutor() },
{ template: BreakColorTemplate, executor: new BreakColorExecutor() },
{ template: ColorFromHexTemplate, executor: new ColorFromHexExecutor() },
{ template: ColorToHexTemplate, executor: new ColorToHexExecutor() },
{ template: ColorFromHSLTemplate, executor: new ColorFromHSLExecutor() },
{ template: ColorToHSLTemplate, executor: new ColorToHSLExecutor() },
{ template: ColorLerpTemplate, executor: new ColorLerpExecutor() },
{ template: ColorLightenTemplate, executor: new ColorLightenExecutor() },
{ template: ColorDarkenTemplate, executor: new ColorDarkenExecutor() },
{ template: ColorSaturateTemplate, executor: new ColorSaturateExecutor() },
{ template: ColorDesaturateTemplate, executor: new ColorDesaturateExecutor() },
{ template: ColorInvertTemplate, executor: new ColorInvertExecutor() },
{ template: ColorGrayscaleTemplate, executor: new ColorGrayscaleExecutor() },
{ template: ColorLuminanceTemplate, executor: new ColorLuminanceExecutor() },
{ template: ColorConstantsTemplate, executor: new ColorConstantsExecutor() }
];

View File

@@ -0,0 +1,662 @@
/**
* @zh Fixed32 定点数蓝图节点
* @en Fixed32 Blueprint Nodes
*/
import type { BlueprintNodeTemplate, BlueprintNode, INodeExecutor, ExecutionResult } from '@esengine/blueprint';
import { Fixed32 } from '../Fixed32';
interface FixedContext {
evaluateInput(nodeId: string, pinName: string, defaultValue?: unknown): unknown;
}
// Make Fixed32 from float
export const Fixed32FromTemplate: BlueprintNodeTemplate = {
type: 'Fixed32From',
title: 'Fixed32 From Float',
category: 'math',
description: 'Creates Fixed32 from float',
keywords: ['fixed', 'fixed32', 'from', 'create', 'deterministic'],
menuPath: ['Math', 'Fixed', 'From Float'],
isPure: true,
inputs: [
{ name: 'value', displayName: 'Value', type: 'float', defaultValue: 0 }
],
outputs: [
{ name: 'fixed', displayName: 'Fixed32', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32FromExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const value = Number(ctx.evaluateInput(node.id, 'value', 0));
return { outputs: { fixed: Fixed32.from(value) } };
}
}
// Make Fixed32 from int
export const Fixed32FromIntTemplate: BlueprintNodeTemplate = {
type: 'Fixed32FromInt',
title: 'Fixed32 From Int',
category: 'math',
description: 'Creates Fixed32 from integer (no precision loss)',
keywords: ['fixed', 'fixed32', 'from', 'int', 'integer'],
menuPath: ['Math', 'Fixed', 'From Int'],
isPure: true,
inputs: [
{ name: 'value', displayName: 'Value', type: 'int', defaultValue: 0 }
],
outputs: [
{ name: 'fixed', displayName: 'Fixed32', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32FromIntExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const value = Math.floor(Number(ctx.evaluateInput(node.id, 'value', 0)));
return { outputs: { fixed: Fixed32.fromInt(value) } };
}
}
// Fixed32 to float
export const Fixed32ToFloatTemplate: BlueprintNodeTemplate = {
type: 'Fixed32ToFloat',
title: 'Fixed32 To Float',
category: 'math',
description: 'Converts Fixed32 to float',
keywords: ['fixed', 'fixed32', 'to', 'float', 'convert'],
menuPath: ['Math', 'Fixed', 'To Float'],
isPure: true,
inputs: [
{ name: 'fixed', displayName: 'Fixed32', type: 'object' }
],
outputs: [
{ name: 'value', displayName: 'Value', type: 'float' }
],
color: '#9C27B0'
};
export class Fixed32ToFloatExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const fixed = ctx.evaluateInput(node.id, 'fixed', Fixed32.ZERO) as Fixed32;
return { outputs: { value: fixed?.toNumber() ?? 0 } };
}
}
// Fixed32 to int
export const Fixed32ToIntTemplate: BlueprintNodeTemplate = {
type: 'Fixed32ToInt',
title: 'Fixed32 To Int',
category: 'math',
description: 'Converts Fixed32 to integer (floor)',
keywords: ['fixed', 'fixed32', 'to', 'int', 'integer'],
menuPath: ['Math', 'Fixed', 'To Int'],
isPure: true,
inputs: [
{ name: 'fixed', displayName: 'Fixed32', type: 'object' }
],
outputs: [
{ name: 'value', displayName: 'Value', type: 'int' }
],
color: '#9C27B0'
};
export class Fixed32ToIntExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const fixed = ctx.evaluateInput(node.id, 'fixed', Fixed32.ZERO) as Fixed32;
return { outputs: { value: fixed?.toInt() ?? 0 } };
}
}
// Fixed32 Add
export const Fixed32AddTemplate: BlueprintNodeTemplate = {
type: 'Fixed32Add',
title: 'Fixed32 +',
category: 'math',
description: 'Adds two Fixed32 values',
keywords: ['fixed', 'add', 'plus', '+'],
menuPath: ['Math', 'Fixed', 'Add'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'object' },
{ name: 'b', displayName: 'B', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32AddExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const a = ctx.evaluateInput(node.id, 'a', Fixed32.ZERO) as Fixed32;
const b = ctx.evaluateInput(node.id, 'b', Fixed32.ZERO) as Fixed32;
return { outputs: { result: (a ?? Fixed32.ZERO).add(b ?? Fixed32.ZERO) } };
}
}
// Fixed32 Subtract
export const Fixed32SubtractTemplate: BlueprintNodeTemplate = {
type: 'Fixed32Subtract',
title: 'Fixed32 -',
category: 'math',
description: 'Subtracts B from A',
keywords: ['fixed', 'subtract', 'minus', '-'],
menuPath: ['Math', 'Fixed', 'Subtract'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'object' },
{ name: 'b', displayName: 'B', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32SubtractExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const a = ctx.evaluateInput(node.id, 'a', Fixed32.ZERO) as Fixed32;
const b = ctx.evaluateInput(node.id, 'b', Fixed32.ZERO) as Fixed32;
return { outputs: { result: (a ?? Fixed32.ZERO).sub(b ?? Fixed32.ZERO) } };
}
}
// Fixed32 Multiply
export const Fixed32MultiplyTemplate: BlueprintNodeTemplate = {
type: 'Fixed32Multiply',
title: 'Fixed32 *',
category: 'math',
description: 'Multiplies two Fixed32 values',
keywords: ['fixed', 'multiply', 'times', '*'],
menuPath: ['Math', 'Fixed', 'Multiply'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'object' },
{ name: 'b', displayName: 'B', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32MultiplyExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const a = ctx.evaluateInput(node.id, 'a', Fixed32.ONE) as Fixed32;
const b = ctx.evaluateInput(node.id, 'b', Fixed32.ONE) as Fixed32;
return { outputs: { result: (a ?? Fixed32.ONE).mul(b ?? Fixed32.ONE) } };
}
}
// Fixed32 Divide
export const Fixed32DivideTemplate: BlueprintNodeTemplate = {
type: 'Fixed32Divide',
title: 'Fixed32 /',
category: 'math',
description: 'Divides A by B',
keywords: ['fixed', 'divide', '/'],
menuPath: ['Math', 'Fixed', 'Divide'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'object' },
{ name: 'b', displayName: 'B', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32DivideExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const a = ctx.evaluateInput(node.id, 'a', Fixed32.ZERO) as Fixed32;
const b = ctx.evaluateInput(node.id, 'b', Fixed32.ONE) as Fixed32;
const divisor = b ?? Fixed32.ONE;
if (divisor.isZero()) {
return { outputs: { result: Fixed32.ZERO } };
}
return { outputs: { result: (a ?? Fixed32.ZERO).div(divisor) } };
}
}
// Fixed32 Negate
export const Fixed32NegateTemplate: BlueprintNodeTemplate = {
type: 'Fixed32Negate',
title: 'Fixed32 Negate',
category: 'math',
description: 'Negates a Fixed32 value',
keywords: ['fixed', 'negate', '-'],
menuPath: ['Math', 'Fixed', 'Negate'],
isPure: true,
inputs: [
{ name: 'value', displayName: 'Value', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32NegateExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const value = ctx.evaluateInput(node.id, 'value', Fixed32.ZERO) as Fixed32;
return { outputs: { result: (value ?? Fixed32.ZERO).neg() } };
}
}
// Fixed32 Abs
export const Fixed32AbsTemplate: BlueprintNodeTemplate = {
type: 'Fixed32Abs',
title: 'Fixed32 Abs',
category: 'math',
description: 'Absolute value of Fixed32',
keywords: ['fixed', 'abs', 'absolute'],
menuPath: ['Math', 'Fixed', 'Abs'],
isPure: true,
inputs: [
{ name: 'value', displayName: 'Value', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32AbsExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const value = ctx.evaluateInput(node.id, 'value', Fixed32.ZERO) as Fixed32;
return { outputs: { result: (value ?? Fixed32.ZERO).abs() } };
}
}
// Fixed32 Sqrt
export const Fixed32SqrtTemplate: BlueprintNodeTemplate = {
type: 'Fixed32Sqrt',
title: 'Fixed32 Sqrt',
category: 'math',
description: 'Square root (deterministic)',
keywords: ['fixed', 'sqrt', 'square', 'root'],
menuPath: ['Math', 'Fixed', 'Sqrt'],
isPure: true,
inputs: [
{ name: 'value', displayName: 'Value', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32SqrtExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const value = ctx.evaluateInput(node.id, 'value', Fixed32.ZERO) as Fixed32;
return { outputs: { result: Fixed32.sqrt(value ?? Fixed32.ZERO) } };
}
}
// Fixed32 Floor
export const Fixed32FloorTemplate: BlueprintNodeTemplate = {
type: 'Fixed32Floor',
title: 'Fixed32 Floor',
category: 'math',
description: 'Floor of Fixed32',
keywords: ['fixed', 'floor', 'round', 'down'],
menuPath: ['Math', 'Fixed', 'Floor'],
isPure: true,
inputs: [
{ name: 'value', displayName: 'Value', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32FloorExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const value = ctx.evaluateInput(node.id, 'value', Fixed32.ZERO) as Fixed32;
return { outputs: { result: Fixed32.floor(value ?? Fixed32.ZERO) } };
}
}
// Fixed32 Ceil
export const Fixed32CeilTemplate: BlueprintNodeTemplate = {
type: 'Fixed32Ceil',
title: 'Fixed32 Ceil',
category: 'math',
description: 'Ceiling of Fixed32',
keywords: ['fixed', 'ceil', 'ceiling', 'round', 'up'],
menuPath: ['Math', 'Fixed', 'Ceil'],
isPure: true,
inputs: [
{ name: 'value', displayName: 'Value', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32CeilExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const value = ctx.evaluateInput(node.id, 'value', Fixed32.ZERO) as Fixed32;
return { outputs: { result: Fixed32.ceil(value ?? Fixed32.ZERO) } };
}
}
// Fixed32 Round
export const Fixed32RoundTemplate: BlueprintNodeTemplate = {
type: 'Fixed32Round',
title: 'Fixed32 Round',
category: 'math',
description: 'Rounds Fixed32 to nearest integer',
keywords: ['fixed', 'round'],
menuPath: ['Math', 'Fixed', 'Round'],
isPure: true,
inputs: [
{ name: 'value', displayName: 'Value', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32RoundExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const value = ctx.evaluateInput(node.id, 'value', Fixed32.ZERO) as Fixed32;
return { outputs: { result: Fixed32.round(value ?? Fixed32.ZERO) } };
}
}
// Fixed32 Sign
export const Fixed32SignTemplate: BlueprintNodeTemplate = {
type: 'Fixed32Sign',
title: 'Fixed32 Sign',
category: 'math',
description: 'Sign of Fixed32 (-1, 0, or 1)',
keywords: ['fixed', 'sign'],
menuPath: ['Math', 'Fixed', 'Sign'],
isPure: true,
inputs: [
{ name: 'value', displayName: 'Value', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32SignExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const value = ctx.evaluateInput(node.id, 'value', Fixed32.ZERO) as Fixed32;
return { outputs: { result: Fixed32.sign(value ?? Fixed32.ZERO) } };
}
}
// Fixed32 Min
export const Fixed32MinTemplate: BlueprintNodeTemplate = {
type: 'Fixed32Min',
title: 'Fixed32 Min',
category: 'math',
description: 'Minimum of two Fixed32 values',
keywords: ['fixed', 'min', 'minimum'],
menuPath: ['Math', 'Fixed', 'Min'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'object' },
{ name: 'b', displayName: 'B', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32MinExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const a = ctx.evaluateInput(node.id, 'a', Fixed32.ZERO) as Fixed32;
const b = ctx.evaluateInput(node.id, 'b', Fixed32.ZERO) as Fixed32;
return { outputs: { result: Fixed32.min(a ?? Fixed32.ZERO, b ?? Fixed32.ZERO) } };
}
}
// Fixed32 Max
export const Fixed32MaxTemplate: BlueprintNodeTemplate = {
type: 'Fixed32Max',
title: 'Fixed32 Max',
category: 'math',
description: 'Maximum of two Fixed32 values',
keywords: ['fixed', 'max', 'maximum'],
menuPath: ['Math', 'Fixed', 'Max'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'object' },
{ name: 'b', displayName: 'B', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32MaxExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const a = ctx.evaluateInput(node.id, 'a', Fixed32.ZERO) as Fixed32;
const b = ctx.evaluateInput(node.id, 'b', Fixed32.ZERO) as Fixed32;
return { outputs: { result: Fixed32.max(a ?? Fixed32.ZERO, b ?? Fixed32.ZERO) } };
}
}
// Fixed32 Clamp
export const Fixed32ClampTemplate: BlueprintNodeTemplate = {
type: 'Fixed32Clamp',
title: 'Fixed32 Clamp',
category: 'math',
description: 'Clamps Fixed32 to range',
keywords: ['fixed', 'clamp', 'limit', 'range'],
menuPath: ['Math', 'Fixed', 'Clamp'],
isPure: true,
inputs: [
{ name: 'value', displayName: 'Value', type: 'object' },
{ name: 'min', displayName: 'Min', type: 'object' },
{ name: 'max', displayName: 'Max', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32ClampExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const value = ctx.evaluateInput(node.id, 'value', Fixed32.ZERO) as Fixed32;
const min = ctx.evaluateInput(node.id, 'min', Fixed32.ZERO) as Fixed32;
const max = ctx.evaluateInput(node.id, 'max', Fixed32.ONE) as Fixed32;
return { outputs: { result: Fixed32.clamp(value ?? Fixed32.ZERO, min ?? Fixed32.ZERO, max ?? Fixed32.ONE) } };
}
}
// Fixed32 Lerp
export const Fixed32LerpTemplate: BlueprintNodeTemplate = {
type: 'Fixed32Lerp',
title: 'Fixed32 Lerp',
category: 'math',
description: 'Linear interpolation between A and B',
keywords: ['fixed', 'lerp', 'interpolate', 'blend'],
menuPath: ['Math', 'Fixed', 'Lerp'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'object' },
{ name: 'b', displayName: 'B', type: 'object' },
{ name: 't', displayName: 'T', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32LerpExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const a = ctx.evaluateInput(node.id, 'a', Fixed32.ZERO) as Fixed32;
const b = ctx.evaluateInput(node.id, 'b', Fixed32.ONE) as Fixed32;
const t = ctx.evaluateInput(node.id, 't', Fixed32.HALF) as Fixed32;
return { outputs: { result: Fixed32.lerp(a ?? Fixed32.ZERO, b ?? Fixed32.ONE, t ?? Fixed32.HALF) } };
}
}
// Fixed32 Compare
export const Fixed32CompareTemplate: BlueprintNodeTemplate = {
type: 'Fixed32Compare',
title: 'Fixed32 Compare',
category: 'math',
description: 'Compares two Fixed32 values',
keywords: ['fixed', 'compare', 'equal', 'less', 'greater'],
menuPath: ['Math', 'Fixed', 'Compare'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'object' },
{ name: 'b', displayName: 'B', type: 'object' }
],
outputs: [
{ name: 'equal', displayName: 'A == B', type: 'bool' },
{ name: 'less', displayName: 'A < B', type: 'bool' },
{ name: 'greater', displayName: 'A > B', type: 'bool' }
],
color: '#9C27B0'
};
export class Fixed32CompareExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const a = ctx.evaluateInput(node.id, 'a', Fixed32.ZERO) as Fixed32;
const b = ctx.evaluateInput(node.id, 'b', Fixed32.ZERO) as Fixed32;
const aVal = a ?? Fixed32.ZERO;
const bVal = b ?? Fixed32.ZERO;
return {
outputs: {
equal: aVal.eq(bVal),
less: aVal.lt(bVal),
greater: aVal.gt(bVal)
}
};
}
}
// Fixed32 IsZero
export const Fixed32IsZeroTemplate: BlueprintNodeTemplate = {
type: 'Fixed32IsZero',
title: 'Fixed32 Is Zero',
category: 'math',
description: 'Checks if Fixed32 is zero, positive, or negative',
keywords: ['fixed', 'zero', 'check'],
menuPath: ['Math', 'Fixed', 'Is Zero'],
isPure: true,
inputs: [
{ name: 'value', displayName: 'Value', type: 'object' }
],
outputs: [
{ name: 'isZero', displayName: 'Is Zero', type: 'bool' },
{ name: 'isPositive', displayName: 'Is Positive', type: 'bool' },
{ name: 'isNegative', displayName: 'Is Negative', type: 'bool' }
],
color: '#9C27B0'
};
export class Fixed32IsZeroExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedContext;
const value = ctx.evaluateInput(node.id, 'value', Fixed32.ZERO) as Fixed32;
const val = value ?? Fixed32.ZERO;
return {
outputs: {
isZero: val.isZero(),
isPositive: val.isPositive(),
isNegative: val.isNegative()
}
};
}
}
// Fixed32 Constants
export const Fixed32ConstantsTemplate: BlueprintNodeTemplate = {
type: 'Fixed32Constants',
title: 'Fixed32 Constants',
category: 'math',
description: 'Common Fixed32 constants',
keywords: ['fixed', 'constant', 'pi', 'zero', 'one'],
menuPath: ['Math', 'Fixed', 'Constants'],
isPure: true,
inputs: [],
outputs: [
{ name: 'zero', displayName: '0', type: 'object' },
{ name: 'one', displayName: '1', type: 'object' },
{ name: 'half', displayName: '0.5', type: 'object' },
{ name: 'pi', displayName: 'PI', type: 'object' },
{ name: 'twoPi', displayName: '2PI', type: 'object' }
],
color: '#9C27B0'
};
export class Fixed32ConstantsExecutor implements INodeExecutor {
execute(): ExecutionResult {
return {
outputs: {
zero: Fixed32.ZERO,
one: Fixed32.ONE,
half: Fixed32.HALF,
pi: Fixed32.PI,
twoPi: Fixed32.TWO_PI
}
};
}
}
// Node definitions collection
export const FixedNodeDefinitions = [
{ template: Fixed32FromTemplate, executor: new Fixed32FromExecutor() },
{ template: Fixed32FromIntTemplate, executor: new Fixed32FromIntExecutor() },
{ template: Fixed32ToFloatTemplate, executor: new Fixed32ToFloatExecutor() },
{ template: Fixed32ToIntTemplate, executor: new Fixed32ToIntExecutor() },
{ template: Fixed32AddTemplate, executor: new Fixed32AddExecutor() },
{ template: Fixed32SubtractTemplate, executor: new Fixed32SubtractExecutor() },
{ template: Fixed32MultiplyTemplate, executor: new Fixed32MultiplyExecutor() },
{ template: Fixed32DivideTemplate, executor: new Fixed32DivideExecutor() },
{ template: Fixed32NegateTemplate, executor: new Fixed32NegateExecutor() },
{ template: Fixed32AbsTemplate, executor: new Fixed32AbsExecutor() },
{ template: Fixed32SqrtTemplate, executor: new Fixed32SqrtExecutor() },
{ template: Fixed32FloorTemplate, executor: new Fixed32FloorExecutor() },
{ template: Fixed32CeilTemplate, executor: new Fixed32CeilExecutor() },
{ template: Fixed32RoundTemplate, executor: new Fixed32RoundExecutor() },
{ template: Fixed32SignTemplate, executor: new Fixed32SignExecutor() },
{ template: Fixed32MinTemplate, executor: new Fixed32MinExecutor() },
{ template: Fixed32MaxTemplate, executor: new Fixed32MaxExecutor() },
{ template: Fixed32ClampTemplate, executor: new Fixed32ClampExecutor() },
{ template: Fixed32LerpTemplate, executor: new Fixed32LerpExecutor() },
{ template: Fixed32CompareTemplate, executor: new Fixed32CompareExecutor() },
{ template: Fixed32IsZeroTemplate, executor: new Fixed32IsZeroExecutor() },
{ template: Fixed32ConstantsTemplate, executor: new Fixed32ConstantsExecutor() }
];

View File

@@ -0,0 +1,360 @@
/**
* @zh FixedVector2 定点向量蓝图节点
* @en FixedVector2 Blueprint Nodes
*/
import type { BlueprintNodeTemplate, BlueprintNode, INodeExecutor, ExecutionResult } from '@esengine/blueprint';
import { FixedVector2 } from '../FixedVector2';
import { Fixed32 } from '../Fixed32';
interface FixedVectorContext {
evaluateInput(nodeId: string, pinName: string, defaultValue?: unknown): unknown;
}
// Make FixedVector2
export const MakeFixedVector2Template: BlueprintNodeTemplate = {
type: 'MakeFixedVector2',
title: 'Make FixedVector2',
category: 'math',
description: 'Creates FixedVector2 from floats',
keywords: ['make', 'create', 'fixed', 'vector', 'deterministic'],
menuPath: ['Math', 'Fixed Vector', 'Make FixedVector2'],
isPure: true,
inputs: [
{ name: 'x', displayName: 'X', type: 'float', defaultValue: 0 },
{ name: 'y', displayName: 'Y', type: 'float', defaultValue: 0 }
],
outputs: [
{ name: 'vector', displayName: 'Vector', type: 'object' }
],
color: '#673AB7'
};
export class MakeFixedVector2Executor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedVectorContext;
const x = Number(ctx.evaluateInput(node.id, 'x', 0));
const y = Number(ctx.evaluateInput(node.id, 'y', 0));
return { outputs: { vector: FixedVector2.from(x, y) } };
}
}
// Break FixedVector2
export const BreakFixedVector2Template: BlueprintNodeTemplate = {
type: 'BreakFixedVector2',
title: 'Break FixedVector2',
category: 'math',
description: 'Breaks FixedVector2 into X and Y floats',
keywords: ['break', 'split', 'fixed', 'vector'],
menuPath: ['Math', 'Fixed Vector', 'Break FixedVector2'],
isPure: true,
inputs: [
{ name: 'vector', displayName: 'Vector', type: 'object' }
],
outputs: [
{ name: 'x', displayName: 'X', type: 'float' },
{ name: 'y', displayName: 'Y', type: 'float' }
],
color: '#673AB7'
};
export class BreakFixedVector2Executor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedVectorContext;
const vector = ctx.evaluateInput(node.id, 'vector', FixedVector2.ZERO) as FixedVector2;
const v = vector ?? FixedVector2.ZERO;
return { outputs: { x: v.x.toNumber(), y: v.y.toNumber() } };
}
}
// FixedVector2 Add
export const FixedVector2AddTemplate: BlueprintNodeTemplate = {
type: 'FixedVector2Add',
title: 'FixedVector2 +',
category: 'math',
description: 'Adds two fixed vectors',
keywords: ['fixed', 'vector', 'add', '+'],
menuPath: ['Math', 'Fixed Vector', 'Add'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'object' },
{ name: 'b', displayName: 'B', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#673AB7'
};
export class FixedVector2AddExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedVectorContext;
const a = ctx.evaluateInput(node.id, 'a', FixedVector2.ZERO) as FixedVector2;
const b = ctx.evaluateInput(node.id, 'b', FixedVector2.ZERO) as FixedVector2;
return { outputs: { result: (a ?? FixedVector2.ZERO).add(b ?? FixedVector2.ZERO) } };
}
}
// FixedVector2 Subtract
export const FixedVector2SubtractTemplate: BlueprintNodeTemplate = {
type: 'FixedVector2Subtract',
title: 'FixedVector2 -',
category: 'math',
description: 'Subtracts B from A',
keywords: ['fixed', 'vector', 'subtract', '-'],
menuPath: ['Math', 'Fixed Vector', 'Subtract'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'object' },
{ name: 'b', displayName: 'B', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#673AB7'
};
export class FixedVector2SubtractExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedVectorContext;
const a = ctx.evaluateInput(node.id, 'a', FixedVector2.ZERO) as FixedVector2;
const b = ctx.evaluateInput(node.id, 'b', FixedVector2.ZERO) as FixedVector2;
return { outputs: { result: (a ?? FixedVector2.ZERO).sub(b ?? FixedVector2.ZERO) } };
}
}
// FixedVector2 Scale
export const FixedVector2ScaleTemplate: BlueprintNodeTemplate = {
type: 'FixedVector2Scale',
title: 'FixedVector2 *',
category: 'math',
description: 'Scales vector by Fixed32 scalar',
keywords: ['fixed', 'vector', 'scale', '*'],
menuPath: ['Math', 'Fixed Vector', 'Scale'],
isPure: true,
inputs: [
{ name: 'vector', displayName: 'Vector', type: 'object' },
{ name: 'scalar', displayName: 'Scalar', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#673AB7'
};
export class FixedVector2ScaleExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedVectorContext;
const vector = ctx.evaluateInput(node.id, 'vector', FixedVector2.ZERO) as FixedVector2;
const scalar = ctx.evaluateInput(node.id, 'scalar', Fixed32.ONE) as Fixed32;
return { outputs: { result: (vector ?? FixedVector2.ZERO).mul(scalar ?? Fixed32.ONE) } };
}
}
// FixedVector2 Negate
export const FixedVector2NegateTemplate: BlueprintNodeTemplate = {
type: 'FixedVector2Negate',
title: 'FixedVector2 Negate',
category: 'math',
description: 'Negates a fixed vector',
keywords: ['fixed', 'vector', 'negate', '-'],
menuPath: ['Math', 'Fixed Vector', 'Negate'],
isPure: true,
inputs: [
{ name: 'vector', displayName: 'Vector', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#673AB7'
};
export class FixedVector2NegateExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedVectorContext;
const vector = ctx.evaluateInput(node.id, 'vector', FixedVector2.ZERO) as FixedVector2;
return { outputs: { result: (vector ?? FixedVector2.ZERO).neg() } };
}
}
// FixedVector2 Length
export const FixedVector2LengthTemplate: BlueprintNodeTemplate = {
type: 'FixedVector2Length',
title: 'FixedVector2 Length',
category: 'math',
description: 'Gets the length of a fixed vector',
keywords: ['fixed', 'vector', 'length', 'magnitude'],
menuPath: ['Math', 'Fixed Vector', 'Length'],
isPure: true,
inputs: [
{ name: 'vector', displayName: 'Vector', type: 'object' }
],
outputs: [
{ name: 'length', displayName: 'Length', type: 'object' }
],
color: '#673AB7'
};
export class FixedVector2LengthExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedVectorContext;
const vector = ctx.evaluateInput(node.id, 'vector', FixedVector2.ZERO) as FixedVector2;
return { outputs: { length: (vector ?? FixedVector2.ZERO).length() } };
}
}
// FixedVector2 Normalize
export const FixedVector2NormalizeTemplate: BlueprintNodeTemplate = {
type: 'FixedVector2Normalize',
title: 'FixedVector2 Normalize',
category: 'math',
description: 'Normalizes a fixed vector',
keywords: ['fixed', 'vector', 'normalize', 'unit'],
menuPath: ['Math', 'Fixed Vector', 'Normalize'],
isPure: true,
inputs: [
{ name: 'vector', displayName: 'Vector', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#673AB7'
};
export class FixedVector2NormalizeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedVectorContext;
const vector = ctx.evaluateInput(node.id, 'vector', FixedVector2.ZERO) as FixedVector2;
return { outputs: { result: (vector ?? FixedVector2.ZERO).normalize() } };
}
}
// FixedVector2 Dot
export const FixedVector2DotTemplate: BlueprintNodeTemplate = {
type: 'FixedVector2Dot',
title: 'FixedVector2 Dot',
category: 'math',
description: 'Calculates dot product',
keywords: ['fixed', 'vector', 'dot', 'product'],
menuPath: ['Math', 'Fixed Vector', 'Dot Product'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'object' },
{ name: 'b', displayName: 'B', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#673AB7'
};
export class FixedVector2DotExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedVectorContext;
const a = ctx.evaluateInput(node.id, 'a', FixedVector2.ZERO) as FixedVector2;
const b = ctx.evaluateInput(node.id, 'b', FixedVector2.ZERO) as FixedVector2;
return { outputs: { result: FixedVector2.dot(a ?? FixedVector2.ZERO, b ?? FixedVector2.ZERO) } };
}
}
// FixedVector2 Cross
export const FixedVector2CrossTemplate: BlueprintNodeTemplate = {
type: 'FixedVector2Cross',
title: 'FixedVector2 Cross',
category: 'math',
description: '2D cross product (returns Fixed32)',
keywords: ['fixed', 'vector', 'cross', 'product'],
menuPath: ['Math', 'Fixed Vector', 'Cross Product'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'object' },
{ name: 'b', displayName: 'B', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#673AB7'
};
export class FixedVector2CrossExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedVectorContext;
const a = ctx.evaluateInput(node.id, 'a', FixedVector2.ZERO) as FixedVector2;
const b = ctx.evaluateInput(node.id, 'b', FixedVector2.ZERO) as FixedVector2;
return { outputs: { result: FixedVector2.cross(a ?? FixedVector2.ZERO, b ?? FixedVector2.ZERO) } };
}
}
// FixedVector2 Distance
export const FixedVector2DistanceTemplate: BlueprintNodeTemplate = {
type: 'FixedVector2Distance',
title: 'FixedVector2 Distance',
category: 'math',
description: 'Distance between two points',
keywords: ['fixed', 'vector', 'distance'],
menuPath: ['Math', 'Fixed Vector', 'Distance'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'object' },
{ name: 'b', displayName: 'B', type: 'object' }
],
outputs: [
{ name: 'distance', displayName: 'Distance', type: 'object' }
],
color: '#673AB7'
};
export class FixedVector2DistanceExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedVectorContext;
const a = ctx.evaluateInput(node.id, 'a', FixedVector2.ZERO) as FixedVector2;
const b = ctx.evaluateInput(node.id, 'b', FixedVector2.ZERO) as FixedVector2;
return { outputs: { distance: FixedVector2.distance(a ?? FixedVector2.ZERO, b ?? FixedVector2.ZERO) } };
}
}
// FixedVector2 Lerp
export const FixedVector2LerpTemplate: BlueprintNodeTemplate = {
type: 'FixedVector2Lerp',
title: 'FixedVector2 Lerp',
category: 'math',
description: 'Linear interpolation between two vectors',
keywords: ['fixed', 'vector', 'lerp', 'interpolate'],
menuPath: ['Math', 'Fixed Vector', 'Lerp'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'object' },
{ name: 'b', displayName: 'B', type: 'object' },
{ name: 't', displayName: 'T', type: 'object' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'object' }
],
color: '#673AB7'
};
export class FixedVector2LerpExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as FixedVectorContext;
const a = ctx.evaluateInput(node.id, 'a', FixedVector2.ZERO) as FixedVector2;
const b = ctx.evaluateInput(node.id, 'b', FixedVector2.ZERO) as FixedVector2;
const t = ctx.evaluateInput(node.id, 't', Fixed32.HALF) as Fixed32;
return { outputs: { result: FixedVector2.lerp(a ?? FixedVector2.ZERO, b ?? FixedVector2.ZERO, t ?? Fixed32.HALF) } };
}
}
// Node definitions collection
export const FixedVectorNodeDefinitions = [
{ template: MakeFixedVector2Template, executor: new MakeFixedVector2Executor() },
{ template: BreakFixedVector2Template, executor: new BreakFixedVector2Executor() },
{ template: FixedVector2AddTemplate, executor: new FixedVector2AddExecutor() },
{ template: FixedVector2SubtractTemplate, executor: new FixedVector2SubtractExecutor() },
{ template: FixedVector2ScaleTemplate, executor: new FixedVector2ScaleExecutor() },
{ template: FixedVector2NegateTemplate, executor: new FixedVector2NegateExecutor() },
{ template: FixedVector2LengthTemplate, executor: new FixedVector2LengthExecutor() },
{ template: FixedVector2NormalizeTemplate, executor: new FixedVector2NormalizeExecutor() },
{ template: FixedVector2DotTemplate, executor: new FixedVector2DotExecutor() },
{ template: FixedVector2CrossTemplate, executor: new FixedVector2CrossExecutor() },
{ template: FixedVector2DistanceTemplate, executor: new FixedVector2DistanceExecutor() },
{ template: FixedVector2LerpTemplate, executor: new FixedVector2LerpExecutor() }
];

View File

@@ -0,0 +1,387 @@
/**
* @zh Vector2 蓝图节点
* @en Vector2 Blueprint Nodes
*/
import type { BlueprintNodeTemplate, BlueprintNode, INodeExecutor, ExecutionResult } from '@esengine/blueprint';
import { Vector2 } from '../Vector2';
interface VectorContext {
evaluateInput(nodeId: string, pinName: string, defaultValue?: unknown): unknown;
}
// Make Vector2
export const MakeVector2Template: BlueprintNodeTemplate = {
type: 'MakeVector2',
title: 'Make Vector2',
category: 'math',
description: 'Creates a Vector2 from X and Y',
keywords: ['make', 'create', 'vector', 'vector2'],
menuPath: ['Math', 'Vector', 'Make Vector2'],
isPure: true,
inputs: [
{ name: 'x', displayName: 'X', type: 'float', defaultValue: 0 },
{ name: 'y', displayName: 'Y', type: 'float', defaultValue: 0 }
],
outputs: [
{ name: 'vector', displayName: 'Vector', type: 'vector2' }
],
color: '#2196F3'
};
export class MakeVector2Executor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as VectorContext;
const x = Number(ctx.evaluateInput(node.id, 'x', 0));
const y = Number(ctx.evaluateInput(node.id, 'y', 0));
return { outputs: { vector: new Vector2(x, y) } };
}
}
// Break Vector2
export const BreakVector2Template: BlueprintNodeTemplate = {
type: 'BreakVector2',
title: 'Break Vector2',
category: 'math',
description: 'Breaks a Vector2 into X and Y',
keywords: ['break', 'split', 'vector', 'vector2'],
menuPath: ['Math', 'Vector', 'Break Vector2'],
isPure: true,
inputs: [
{ name: 'vector', displayName: 'Vector', type: 'vector2' }
],
outputs: [
{ name: 'x', displayName: 'X', type: 'float' },
{ name: 'y', displayName: 'Y', type: 'float' }
],
color: '#2196F3'
};
export class BreakVector2Executor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as VectorContext;
const vector = ctx.evaluateInput(node.id, 'vector', Vector2.ZERO) as Vector2;
return { outputs: { x: vector?.x ?? 0, y: vector?.y ?? 0 } };
}
}
// Vector2 Add
export const Vector2AddTemplate: BlueprintNodeTemplate = {
type: 'Vector2Add',
title: 'Vector2 +',
category: 'math',
description: 'Adds two vectors',
keywords: ['add', 'plus', 'vector'],
menuPath: ['Math', 'Vector', 'Add'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'vector2' },
{ name: 'b', displayName: 'B', type: 'vector2' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'vector2' }
],
color: '#2196F3'
};
export class Vector2AddExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as VectorContext;
const a = ctx.evaluateInput(node.id, 'a', Vector2.ZERO) as Vector2;
const b = ctx.evaluateInput(node.id, 'b', Vector2.ZERO) as Vector2;
return { outputs: { result: Vector2.add(a ?? Vector2.ZERO, b ?? Vector2.ZERO) } };
}
}
// Vector2 Subtract
export const Vector2SubtractTemplate: BlueprintNodeTemplate = {
type: 'Vector2Subtract',
title: 'Vector2 -',
category: 'math',
description: 'Subtracts B from A',
keywords: ['subtract', 'minus', 'vector'],
menuPath: ['Math', 'Vector', 'Subtract'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'vector2' },
{ name: 'b', displayName: 'B', type: 'vector2' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'vector2' }
],
color: '#2196F3'
};
export class Vector2SubtractExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as VectorContext;
const a = ctx.evaluateInput(node.id, 'a', Vector2.ZERO) as Vector2;
const b = ctx.evaluateInput(node.id, 'b', Vector2.ZERO) as Vector2;
return { outputs: { result: Vector2.subtract(a ?? Vector2.ZERO, b ?? Vector2.ZERO) } };
}
}
// Vector2 Scale
export const Vector2ScaleTemplate: BlueprintNodeTemplate = {
type: 'Vector2Scale',
title: 'Vector2 *',
category: 'math',
description: 'Scales a vector by a scalar',
keywords: ['scale', 'multiply', 'vector'],
menuPath: ['Math', 'Vector', 'Scale'],
isPure: true,
inputs: [
{ name: 'vector', displayName: 'Vector', type: 'vector2' },
{ name: 'scalar', displayName: 'Scalar', type: 'float', defaultValue: 1 }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'vector2' }
],
color: '#2196F3'
};
export class Vector2ScaleExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as VectorContext;
const vector = ctx.evaluateInput(node.id, 'vector', Vector2.ZERO) as Vector2;
const scalar = Number(ctx.evaluateInput(node.id, 'scalar', 1));
return { outputs: { result: Vector2.multiply(vector ?? Vector2.ZERO, scalar) } };
}
}
// Vector2 Length
export const Vector2LengthTemplate: BlueprintNodeTemplate = {
type: 'Vector2Length',
title: 'Vector2 Length',
category: 'math',
description: 'Gets the length of a vector',
keywords: ['length', 'magnitude', 'vector'],
menuPath: ['Math', 'Vector', 'Length'],
isPure: true,
inputs: [
{ name: 'vector', displayName: 'Vector', type: 'vector2' }
],
outputs: [
{ name: 'length', displayName: 'Length', type: 'float' }
],
color: '#2196F3'
};
export class Vector2LengthExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as VectorContext;
const vector = ctx.evaluateInput(node.id, 'vector', Vector2.ZERO) as Vector2;
return { outputs: { length: (vector ?? Vector2.ZERO).length } };
}
}
// Vector2 Normalize
export const Vector2NormalizeTemplate: BlueprintNodeTemplate = {
type: 'Vector2Normalize',
title: 'Vector2 Normalize',
category: 'math',
description: 'Normalizes a vector to unit length',
keywords: ['normalize', 'unit', 'vector'],
menuPath: ['Math', 'Vector', 'Normalize'],
isPure: true,
inputs: [
{ name: 'vector', displayName: 'Vector', type: 'vector2' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'vector2' }
],
color: '#2196F3'
};
export class Vector2NormalizeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as VectorContext;
const vector = ctx.evaluateInput(node.id, 'vector', Vector2.ZERO) as Vector2;
return { outputs: { result: (vector ?? Vector2.ZERO).normalized() } };
}
}
// Vector2 Dot
export const Vector2DotTemplate: BlueprintNodeTemplate = {
type: 'Vector2Dot',
title: 'Vector2 Dot',
category: 'math',
description: 'Calculates dot product',
keywords: ['dot', 'product', 'vector'],
menuPath: ['Math', 'Vector', 'Dot Product'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'vector2' },
{ name: 'b', displayName: 'B', type: 'vector2' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'float' }
],
color: '#2196F3'
};
export class Vector2DotExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as VectorContext;
const a = ctx.evaluateInput(node.id, 'a', Vector2.ZERO) as Vector2;
const b = ctx.evaluateInput(node.id, 'b', Vector2.ZERO) as Vector2;
return { outputs: { result: Vector2.dot(a ?? Vector2.ZERO, b ?? Vector2.ZERO) } };
}
}
// Vector2 Cross
export const Vector2CrossTemplate: BlueprintNodeTemplate = {
type: 'Vector2Cross',
title: 'Vector2 Cross',
category: 'math',
description: '2D cross product (returns scalar)',
keywords: ['cross', 'product', 'vector'],
menuPath: ['Math', 'Vector', 'Cross Product'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'vector2' },
{ name: 'b', displayName: 'B', type: 'vector2' }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'float' }
],
color: '#2196F3'
};
export class Vector2CrossExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as VectorContext;
const a = ctx.evaluateInput(node.id, 'a', Vector2.ZERO) as Vector2;
const b = ctx.evaluateInput(node.id, 'b', Vector2.ZERO) as Vector2;
return { outputs: { result: Vector2.cross(a ?? Vector2.ZERO, b ?? Vector2.ZERO) } };
}
}
// Vector2 Distance
export const Vector2DistanceTemplate: BlueprintNodeTemplate = {
type: 'Vector2Distance',
title: 'Vector2 Distance',
category: 'math',
description: 'Distance between two points',
keywords: ['distance', 'length', 'vector'],
menuPath: ['Math', 'Vector', 'Distance'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'vector2' },
{ name: 'b', displayName: 'B', type: 'vector2' }
],
outputs: [
{ name: 'distance', displayName: 'Distance', type: 'float' }
],
color: '#2196F3'
};
export class Vector2DistanceExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as VectorContext;
const a = ctx.evaluateInput(node.id, 'a', Vector2.ZERO) as Vector2;
const b = ctx.evaluateInput(node.id, 'b', Vector2.ZERO) as Vector2;
return { outputs: { distance: Vector2.distance(a ?? Vector2.ZERO, b ?? Vector2.ZERO) } };
}
}
// Vector2 Lerp
export const Vector2LerpTemplate: BlueprintNodeTemplate = {
type: 'Vector2Lerp',
title: 'Vector2 Lerp',
category: 'math',
description: 'Linear interpolation between two vectors',
keywords: ['lerp', 'interpolate', 'vector'],
menuPath: ['Math', 'Vector', 'Lerp'],
isPure: true,
inputs: [
{ name: 'a', displayName: 'A', type: 'vector2' },
{ name: 'b', displayName: 'B', type: 'vector2' },
{ name: 't', displayName: 'T', type: 'float', defaultValue: 0.5 }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'vector2' }
],
color: '#2196F3'
};
export class Vector2LerpExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as VectorContext;
const a = ctx.evaluateInput(node.id, 'a', Vector2.ZERO) as Vector2;
const b = ctx.evaluateInput(node.id, 'b', Vector2.ZERO) as Vector2;
const t = Number(ctx.evaluateInput(node.id, 't', 0.5));
return { outputs: { result: Vector2.lerp(a ?? Vector2.ZERO, b ?? Vector2.ZERO, t) } };
}
}
// Vector2 Rotate
export const Vector2RotateTemplate: BlueprintNodeTemplate = {
type: 'Vector2Rotate',
title: 'Vector2 Rotate',
category: 'math',
description: 'Rotates a vector by angle (radians)',
keywords: ['rotate', 'turn', 'vector'],
menuPath: ['Math', 'Vector', 'Rotate'],
isPure: true,
inputs: [
{ name: 'vector', displayName: 'Vector', type: 'vector2' },
{ name: 'angle', displayName: 'Angle (rad)', type: 'float', defaultValue: 0 }
],
outputs: [
{ name: 'result', displayName: 'Result', type: 'vector2' }
],
color: '#2196F3'
};
export class Vector2RotateExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as VectorContext;
const vector = ctx.evaluateInput(node.id, 'vector', Vector2.ZERO) as Vector2;
const angle = Number(ctx.evaluateInput(node.id, 'angle', 0));
return { outputs: { result: (vector ?? Vector2.ZERO).rotated(angle) } };
}
}
// Vector2 From Angle
export const Vector2FromAngleTemplate: BlueprintNodeTemplate = {
type: 'Vector2FromAngle',
title: 'Vector2 From Angle',
category: 'math',
description: 'Creates unit vector from angle (radians)',
keywords: ['from', 'angle', 'direction', 'vector'],
menuPath: ['Math', 'Vector', 'From Angle'],
isPure: true,
inputs: [
{ name: 'angle', displayName: 'Angle (rad)', type: 'float', defaultValue: 0 }
],
outputs: [
{ name: 'vector', displayName: 'Vector', type: 'vector2' }
],
color: '#2196F3'
};
export class Vector2FromAngleExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as VectorContext;
const angle = Number(ctx.evaluateInput(node.id, 'angle', 0));
return { outputs: { vector: Vector2.fromAngle(angle) } };
}
}
// Node definitions collection
export const VectorNodeDefinitions = [
{ template: MakeVector2Template, executor: new MakeVector2Executor() },
{ template: BreakVector2Template, executor: new BreakVector2Executor() },
{ template: Vector2AddTemplate, executor: new Vector2AddExecutor() },
{ template: Vector2SubtractTemplate, executor: new Vector2SubtractExecutor() },
{ template: Vector2ScaleTemplate, executor: new Vector2ScaleExecutor() },
{ template: Vector2LengthTemplate, executor: new Vector2LengthExecutor() },
{ template: Vector2NormalizeTemplate, executor: new Vector2NormalizeExecutor() },
{ template: Vector2DotTemplate, executor: new Vector2DotExecutor() },
{ template: Vector2CrossTemplate, executor: new Vector2CrossExecutor() },
{ template: Vector2DistanceTemplate, executor: new Vector2DistanceExecutor() },
{ template: Vector2LerpTemplate, executor: new Vector2LerpExecutor() },
{ template: Vector2RotateTemplate, executor: new Vector2RotateExecutor() },
{ template: Vector2FromAngleTemplate, executor: new Vector2FromAngleExecutor() }
];

View File

@@ -0,0 +1,29 @@
/**
* @zh 数学库蓝图节点
* @en Math Library Blueprint Nodes
*
* @zh 导出所有数学相关的蓝图节点
* @en Exports all math-related blueprint nodes
*/
export * from './VectorNodes';
export * from './FixedNodes';
export * from './FixedVectorNodes';
export * from './ColorNodes';
// Re-export node definition collections
import { VectorNodeDefinitions } from './VectorNodes';
import { FixedNodeDefinitions } from './FixedNodes';
import { FixedVectorNodeDefinitions } from './FixedVectorNodes';
import { ColorNodeDefinitions } from './ColorNodes';
/**
* @zh 所有数学库蓝图节点定义
* @en All math library blueprint node definitions
*/
export const MathNodeDefinitions = [
...VectorNodeDefinitions,
...FixedNodeDefinitions,
...FixedVectorNodeDefinitions,
...ColorNodeDefinitions
];

View File

@@ -0,0 +1,225 @@
import { Fixed32 } from '../src/Fixed32';
import { FixedMath } from '../src/FixedMath';
describe('Fixed32', () => {
describe('创建和转换', () => {
test('from 应正确从浮点数创建', () => {
const a = Fixed32.from(3.5);
expect(a.toNumber()).toBeCloseTo(3.5, 4);
});
test('fromInt 应正确从整数创建', () => {
const a = Fixed32.fromInt(42);
expect(a.toInt()).toBe(42);
expect(a.toNumber()).toBe(42);
});
test('fromRaw 应正确从原始值创建', () => {
const raw = 65536 * 2; // 2.0
const a = Fixed32.fromRaw(raw);
expect(a.toNumber()).toBe(2);
});
test('常量应正确', () => {
expect(Fixed32.ZERO.toNumber()).toBe(0);
expect(Fixed32.ONE.toNumber()).toBe(1);
expect(Fixed32.HALF.toNumber()).toBe(0.5);
expect(Fixed32.PI.toNumber()).toBeCloseTo(Math.PI, 3);
});
});
describe('基础运算', () => {
test('add 应正确计算', () => {
const a = Fixed32.from(2.5);
const b = Fixed32.from(1.5);
expect(a.add(b).toNumber()).toBeCloseTo(4, 4);
});
test('sub 应正确计算', () => {
const a = Fixed32.from(5);
const b = Fixed32.from(3);
expect(a.sub(b).toNumber()).toBeCloseTo(2, 4);
});
test('mul 应正确计算', () => {
const a = Fixed32.from(3);
const b = Fixed32.from(4);
expect(a.mul(b).toNumber()).toBeCloseTo(12, 4);
});
test('mul 应正确处理小数', () => {
const a = Fixed32.from(2.5);
const b = Fixed32.from(1.5);
expect(a.mul(b).toNumber()).toBeCloseTo(3.75, 4);
});
test('div 应正确计算', () => {
const a = Fixed32.from(10);
const b = Fixed32.from(4);
expect(a.div(b).toNumber()).toBeCloseTo(2.5, 4);
});
test('div 应抛出除零错误', () => {
const a = Fixed32.from(10);
expect(() => a.div(Fixed32.ZERO)).toThrow('Division by zero');
});
test('neg 应正确取反', () => {
const a = Fixed32.from(5);
expect(a.neg().toNumber()).toBeCloseTo(-5, 4);
});
test('abs 应正确取绝对值', () => {
const a = Fixed32.from(-5);
expect(a.abs().toNumber()).toBeCloseTo(5, 4);
});
});
describe('比较运算', () => {
test('eq 应正确比较', () => {
const a = Fixed32.from(5);
const b = Fixed32.from(5);
const c = Fixed32.from(6);
expect(a.eq(b)).toBe(true);
expect(a.eq(c)).toBe(false);
});
test('lt/le/gt/ge 应正确比较', () => {
const a = Fixed32.from(3);
const b = Fixed32.from(5);
expect(a.lt(b)).toBe(true);
expect(a.le(b)).toBe(true);
expect(b.gt(a)).toBe(true);
expect(b.ge(a)).toBe(true);
});
});
describe('数学函数', () => {
test('sqrt 应正确计算', () => {
const a = Fixed32.from(16);
expect(Fixed32.sqrt(a).toNumber()).toBeCloseTo(4, 3);
const b = Fixed32.from(2);
expect(Fixed32.sqrt(b).toNumber()).toBeCloseTo(Math.sqrt(2), 3);
});
test('floor/ceil/round 应正确计算', () => {
const a = Fixed32.from(3.7);
expect(Fixed32.floor(a).toNumber()).toBeCloseTo(3, 4);
expect(Fixed32.ceil(a).toNumber()).toBeCloseTo(4, 4);
expect(Fixed32.round(a).toNumber()).toBeCloseTo(4, 4);
const b = Fixed32.from(3.2);
expect(Fixed32.round(b).toNumber()).toBeCloseTo(3, 4);
});
test('min/max/clamp 应正确计算', () => {
const a = Fixed32.from(3);
const b = Fixed32.from(5);
expect(Fixed32.min(a, b).toNumber()).toBe(3);
expect(Fixed32.max(a, b).toNumber()).toBe(5);
const x = Fixed32.from(7);
expect(Fixed32.clamp(x, a, b).toNumber()).toBe(5);
});
test('lerp 应正确插值', () => {
const a = Fixed32.from(0);
const b = Fixed32.from(10);
const t = Fixed32.from(0.5);
expect(Fixed32.lerp(a, b, t).toNumber()).toBeCloseTo(5, 4);
});
});
describe('确定性', () => {
test('相同输入应产生相同输出', () => {
const results: number[] = [];
for (let i = 0; i < 100; i++) {
const a = Fixed32.from(3.14159);
const b = Fixed32.from(2.71828);
const result = a.mul(b).add(Fixed32.sqrt(a)).toRaw();
results.push(result);
}
// 所有结果应该完全相同
expect(new Set(results).size).toBe(1);
});
});
});
describe('FixedMath', () => {
describe('三角函数', () => {
test('sin 应正确计算', () => {
expect(FixedMath.sin(Fixed32.ZERO).toNumber()).toBeCloseTo(0, 3);
expect(FixedMath.sin(Fixed32.HALF_PI).toNumber()).toBeCloseTo(1, 3);
expect(FixedMath.sin(Fixed32.PI).toNumber()).toBeCloseTo(0, 2);
});
test('cos 应正确计算', () => {
expect(FixedMath.cos(Fixed32.ZERO).toNumber()).toBeCloseTo(1, 3);
expect(FixedMath.cos(Fixed32.HALF_PI).toNumber()).toBeCloseTo(0, 2);
expect(FixedMath.cos(Fixed32.PI).toNumber()).toBeCloseTo(-1, 3);
});
test('sin²x + cos²x = 1', () => {
const angles = [0, 0.5, 1, 1.5, 2, 2.5, 3];
for (const a of angles) {
const angle = Fixed32.from(a);
const sin = FixedMath.sin(angle);
const cos = FixedMath.cos(angle);
const sum = sin.mul(sin).add(cos.mul(cos));
expect(sum.toNumber()).toBeCloseTo(1, 2);
}
});
test('atan2 应正确计算', () => {
// atan2(0, 1) = 0
expect(FixedMath.atan2(Fixed32.ZERO, Fixed32.ONE).toNumber()).toBeCloseTo(0, 3);
// atan2(1, 0) = π/2
expect(FixedMath.atan2(Fixed32.ONE, Fixed32.ZERO).toNumber()).toBeCloseTo(Math.PI / 2, 2);
// atan2(1, 1) = π/4
expect(FixedMath.atan2(Fixed32.ONE, Fixed32.ONE).toNumber()).toBeCloseTo(Math.PI / 4, 2);
});
});
describe('角度函数', () => {
test('radToDeg/degToRad 应正确转换', () => {
const rad = Fixed32.PI;
const deg = FixedMath.radToDeg(rad);
expect(deg.toNumber()).toBeCloseTo(180, 1);
const deg90 = Fixed32.from(90);
const rad90 = FixedMath.degToRad(deg90);
expect(rad90.toNumber()).toBeCloseTo(Math.PI / 2, 2);
});
test('normalizeAngle 应正确规范化', () => {
const angle1 = Fixed32.from(Math.PI * 3); // 3π -> π
expect(Math.abs(FixedMath.normalizeAngle(angle1).toNumber())).toBeLessThanOrEqual(Math.PI + 0.1);
const angle2 = Fixed32.from(-Math.PI * 3); // -3π -> -π
expect(Math.abs(FixedMath.normalizeAngle(angle2).toNumber())).toBeLessThanOrEqual(Math.PI + 0.1);
});
test('lerpAngle 应走最短路径', () => {
const from = Fixed32.from(0.1);
const to = Fixed32.from(-0.1);
const t = Fixed32.HALF;
const result = FixedMath.lerpAngle(from, to, t);
expect(result.toNumber()).toBeCloseTo(0, 2);
});
});
describe('确定性', () => {
test('三角函数应产生确定性结果', () => {
const results: number[] = [];
for (let i = 0; i < 100; i++) {
const angle = Fixed32.from(1.234);
const result = FixedMath.sin(angle).toRaw();
results.push(result);
}
expect(new Set(results).size).toBe(1);
});
});
});

View File

@@ -0,0 +1,242 @@
import { Fixed32 } from '../src/Fixed32';
import { FixedVector2 } from '../src/FixedVector2';
describe('FixedVector2', () => {
describe('创建和转换', () => {
test('from 应正确从浮点数创建', () => {
const v = FixedVector2.from(3, 4);
const obj = v.toObject();
expect(obj.x).toBeCloseTo(3, 4);
expect(obj.y).toBeCloseTo(4, 4);
});
test('fromInt 应正确从整数创建', () => {
const v = FixedVector2.fromInt(5, 6);
expect(v.x.toInt()).toBe(5);
expect(v.y.toInt()).toBe(6);
});
test('常量应正确', () => {
expect(FixedVector2.ZERO.isZero()).toBe(true);
expect(FixedVector2.ONE.x.toNumber()).toBe(1);
expect(FixedVector2.ONE.y.toNumber()).toBe(1);
expect(FixedVector2.RIGHT.x.toNumber()).toBe(1);
expect(FixedVector2.RIGHT.y.toNumber()).toBe(0);
});
test('toRawObject 应返回原始值', () => {
const v = FixedVector2.from(1, 2);
const raw = v.toRawObject();
expect(raw.x).toBe(Fixed32.from(1).toRaw());
expect(raw.y).toBe(Fixed32.from(2).toRaw());
});
});
describe('基础运算', () => {
test('add 应正确计算', () => {
const a = FixedVector2.from(1, 2);
const b = FixedVector2.from(3, 4);
const result = a.add(b).toObject();
expect(result.x).toBeCloseTo(4, 4);
expect(result.y).toBeCloseTo(6, 4);
});
test('sub 应正确计算', () => {
const a = FixedVector2.from(5, 7);
const b = FixedVector2.from(2, 3);
const result = a.sub(b).toObject();
expect(result.x).toBeCloseTo(3, 4);
expect(result.y).toBeCloseTo(4, 4);
});
test('mul 应正确计算标量乘法', () => {
const v = FixedVector2.from(3, 4);
const result = v.mul(Fixed32.from(2)).toObject();
expect(result.x).toBeCloseTo(6, 4);
expect(result.y).toBeCloseTo(8, 4);
});
test('div 应正确计算标量除法', () => {
const v = FixedVector2.from(6, 8);
const result = v.div(Fixed32.from(2)).toObject();
expect(result.x).toBeCloseTo(3, 4);
expect(result.y).toBeCloseTo(4, 4);
});
test('neg 应正确取反', () => {
const v = FixedVector2.from(3, -4);
const result = v.neg().toObject();
expect(result.x).toBeCloseTo(-3, 4);
expect(result.y).toBeCloseTo(4, 4);
});
});
describe('向量运算', () => {
test('dot 应正确计算点积', () => {
const a = FixedVector2.from(1, 2);
const b = FixedVector2.from(3, 4);
// 1*3 + 2*4 = 11
expect(a.dot(b).toNumber()).toBeCloseTo(11, 4);
});
test('cross 应正确计算叉积', () => {
const a = FixedVector2.from(1, 0);
const b = FixedVector2.from(0, 1);
// 1*1 - 0*0 = 1
expect(a.cross(b).toNumber()).toBeCloseTo(1, 4);
});
test('length 应正确计算', () => {
const v = FixedVector2.from(3, 4);
expect(v.length().toNumber()).toBeCloseTo(5, 3);
});
test('lengthSquared 应正确计算', () => {
const v = FixedVector2.from(3, 4);
expect(v.lengthSquared().toNumber()).toBeCloseTo(25, 4);
});
test('normalize 应正确归一化', () => {
const v = FixedVector2.from(3, 4);
const n = v.normalize();
expect(n.length().toNumber()).toBeCloseTo(1, 2);
expect(n.x.toNumber()).toBeCloseTo(0.6, 2);
expect(n.y.toNumber()).toBeCloseTo(0.8, 2);
});
test('normalize 零向量应返回零向量', () => {
const v = FixedVector2.ZERO;
const n = v.normalize();
expect(n.isZero()).toBe(true);
});
test('distanceTo 应正确计算', () => {
const a = FixedVector2.from(0, 0);
const b = FixedVector2.from(3, 4);
expect(a.distanceTo(b).toNumber()).toBeCloseTo(5, 3);
});
test('perpendicular 应正确计算', () => {
const v = FixedVector2.from(1, 0);
const perp = v.perpendicular();
// 顺时针 90 度: (1, 0) -> (0, -1)
expect(perp.x.toNumber()).toBeCloseTo(0, 4);
expect(perp.y.toNumber()).toBeCloseTo(-1, 4);
});
});
describe('旋转和角度', () => {
test('rotate 应正确旋转', () => {
const v = FixedVector2.from(1, 0);
const angle = Fixed32.HALF_PI; // 90 度
const rotated = v.rotate(angle);
// 顺时针旋转 90 度: (1, 0) -> (0, -1)
expect(rotated.x.toNumber()).toBeCloseTo(0, 2);
expect(rotated.y.toNumber()).toBeCloseTo(-1, 2);
});
test('angle 应正确计算', () => {
const v = FixedVector2.from(1, 0);
expect(v.angle().toNumber()).toBeCloseTo(0, 3);
const v2 = FixedVector2.from(0, 1);
expect(v2.angle().toNumber()).toBeCloseTo(Math.PI / 2, 2);
});
test('fromAngle 应正确创建', () => {
const v = FixedVector2.fromAngle(Fixed32.ZERO);
expect(v.x.toNumber()).toBeCloseTo(1, 3);
expect(v.y.toNumber()).toBeCloseTo(0, 3);
});
test('fromPolar 应正确创建', () => {
const v = FixedVector2.fromPolar(Fixed32.from(5), Fixed32.ZERO);
expect(v.x.toNumber()).toBeCloseTo(5, 3);
expect(v.y.toNumber()).toBeCloseTo(0, 3);
});
});
describe('插值和限制', () => {
test('lerp 应正确插值', () => {
const a = FixedVector2.from(0, 0);
const b = FixedVector2.from(10, 20);
const result = a.lerp(b, Fixed32.HALF).toObject();
expect(result.x).toBeCloseTo(5, 4);
expect(result.y).toBeCloseTo(10, 4);
});
test('clampLength 应正确限制长度', () => {
const v = FixedVector2.from(6, 8); // 长度 10
const clamped = v.clampLength(Fixed32.from(5));
expect(clamped.length().toNumber()).toBeCloseTo(5, 2);
});
test('moveTowards 应正确移动', () => {
const a = FixedVector2.from(0, 0);
const b = FixedVector2.from(10, 0);
const result = a.moveTowards(b, Fixed32.from(3));
expect(result.x.toNumber()).toBeCloseTo(3, 3);
expect(result.y.toNumber()).toBeCloseTo(0, 3);
});
});
describe('比较运算', () => {
test('equals 应正确比较', () => {
const a = FixedVector2.from(3, 4);
const b = FixedVector2.from(3, 4);
const c = FixedVector2.from(3, 5);
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
test('isZero 应正确判断', () => {
expect(FixedVector2.ZERO.isZero()).toBe(true);
expect(FixedVector2.ONE.isZero()).toBe(false);
});
});
describe('确定性', () => {
test('向量运算应产生确定性结果', () => {
const results: string[] = [];
for (let i = 0; i < 100; i++) {
const a = FixedVector2.from(3.14159, 2.71828);
const b = FixedVector2.from(1.41421, 1.73205);
const result = a.add(b).mul(Fixed32.from(0.5)).normalize();
results.push(`${result.x.toRaw()},${result.y.toRaw()}`);
}
expect(new Set(results).size).toBe(1);
});
test('旋转应产生确定性结果', () => {
const results: string[] = [];
for (let i = 0; i < 100; i++) {
const v = FixedVector2.from(1, 0);
const angle = Fixed32.from(0.7853981634); // π/4
const rotated = v.rotate(angle);
results.push(`${rotated.x.toRaw()},${rotated.y.toRaw()}`);
}
expect(new Set(results).size).toBe(1);
});
});
describe('静态方法', () => {
test('distance 应正确计算', () => {
const a = FixedVector2.from(0, 0);
const b = FixedVector2.from(3, 4);
expect(FixedVector2.distance(a, b).toNumber()).toBeCloseTo(5, 3);
});
test('min/max 应正确计算', () => {
const a = FixedVector2.from(1, 5);
const b = FixedVector2.from(3, 2);
const min = FixedVector2.min(a, b);
expect(min.x.toNumber()).toBeCloseTo(1, 4);
expect(min.y.toNumber()).toBeCloseTo(2, 4);
const max = FixedVector2.max(a, b);
expect(max.x.toNumber()).toBeCloseTo(3, 4);
expect(max.y.toNumber()).toBeCloseTo(5, 4);
});
});
});

View File

@@ -1,5 +1,50 @@
# @esengine/network
## 13.0.0
### Patch Changes
- Updated dependencies [[`4e66bd8`](https://github.com/esengine/esengine/commit/4e66bd8e2be80b366a7723dcc48b99df0457aed4)]:
- @esengine/blueprint@4.5.0
- @esengine/ecs-framework-math@2.10.1
## 12.0.0
### Patch Changes
- Updated dependencies [[`fa593a3`](https://github.com/esengine/esengine/commit/fa593a3c69292207800750f8106f418465cb7c0f)]:
- @esengine/ecs-framework-math@2.10.0
## 11.0.0
### Patch Changes
- Updated dependencies [[`bffe90b`](https://github.com/esengine/esengine/commit/bffe90b6a17563cc90709faf339b229dc3abd22d)]:
- @esengine/ecs-framework-math@2.9.0
## 10.0.0
### Minor Changes
- [#440](https://github.com/esengine/esengine/pull/440) [`30173f0`](https://github.com/esengine/esengine/commit/30173f076415c9770a429b236b8bab95a2fdc498) Thanks [@esengine](https://github.com/esengine)! - feat(network): 添加定点数网络同步支持 | Add fixed-point network sync support
**@esengine/network** - 新增定点数同步模块 | Add fixed-point sync module
- 新增 `FixedSnapshotBuffer`:定点数快照缓冲区 | Add `FixedSnapshotBuffer`: fixed-point snapshot buffer
- 新增 `FixedClientPrediction`:定点数客户端预测 | Add `FixedClientPrediction`: fixed-point client prediction
- 支持确定性帧同步和状态回滚 | Support deterministic lockstep and state rollback
### Patch Changes
- Updated dependencies [[`30173f0`](https://github.com/esengine/esengine/commit/30173f076415c9770a429b236b8bab95a2fdc498)]:
- @esengine/ecs-framework-math@2.8.0
## 9.0.0
### Patch Changes
- Updated dependencies [[`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b)]:
- @esengine/blueprint@4.4.0
## 8.0.0
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/network",
"version": "8.0.0",
"version": "13.0.0",
"description": "Network synchronization for multiplayer games",
"esengine": {
"plugin": true,
@@ -32,6 +32,7 @@
},
"peerDependencies": {
"@esengine/ecs-framework": "workspace:*",
"@esengine/ecs-framework-math": "workspace:*",
"@esengine/blueprint": "workspace:*"
},
"peerDependenciesMeta": {
@@ -42,6 +43,7 @@
"devDependencies": {
"@esengine/blueprint": "workspace:*",
"@esengine/ecs-framework": "workspace:*",
"@esengine/ecs-framework-math": "workspace:*",
"@esengine/build-config": "workspace:*",
"rimraf": "^5.0.5",
"tsup": "^8.0.0",

View File

@@ -138,6 +138,11 @@ export type {
ComponentSyncEvent,
ComponentSyncEventListener,
ComponentSyncConfig,
// Fixed-point sync types
IFixedTransformStateRaw,
IFixedTransformStateWithVelocityRaw,
IFixedInterpolator,
IFixedExtrapolator,
} from './sync'
export {
@@ -158,6 +163,15 @@ export {
// Component sync
ComponentSyncSystem,
createComponentSyncSystem,
// Fixed-point sync (Deterministic Lockstep)
FixedTransformState,
FixedTransformStateWithVelocity,
createZeroFixedTransformState,
createZeroFixedTransformStateWithVelocity,
FixedTransformInterpolator,
FixedHermiteTransformInterpolator,
createFixedTransformInterpolator,
createFixedHermiteTransformInterpolator,
} from './sync'
// ============================================================================

View File

@@ -0,0 +1,485 @@
/**
* @zh 定点数客户端预测
* @en Fixed-point Client Prediction
*
* @zh 用于帧同步的确定性客户端预测和回滚
* @en Deterministic client prediction and rollback for lockstep
*/
import { Fixed32, FixedVector2 } from '@esengine/ecs-framework-math';
// =============================================================================
// 定点数输入快照接口 | Fixed Input Snapshot Interface
// =============================================================================
/**
* @zh 定点数输入快照
* @en Fixed-point input snapshot
*/
export interface IFixedInputSnapshot<TInput> {
/**
* @zh 输入帧号
* @en Input frame number
*/
readonly frame: number;
/**
* @zh 输入数据
* @en Input data
*/
readonly input: TInput;
}
/**
* @zh 定点数预测状态
* @en Fixed-point predicted state
*/
export interface IFixedPredictedState<TState> {
/**
* @zh 状态数据
* @en State data
*/
readonly state: TState;
/**
* @zh 对应的帧号
* @en Corresponding frame number
*/
readonly frame: number;
}
// =============================================================================
// 定点数预测器接口 | Fixed Predictor Interface
// =============================================================================
/**
* @zh 定点数状态预测器接口
* @en Fixed-point state predictor interface
*
* @zh 必须使用定点数运算确保确定性
* @en Must use fixed-point arithmetic to ensure determinism
*/
export interface IFixedPredictor<TState, TInput> {
/**
* @zh 根据当前状态和输入预测下一状态
* @en Predict next state based on current state and input
*
* @param state - @zh 当前状态 @en Current state
* @param input - @zh 输入 @en Input
* @param deltaTime - @zh 固定时间步长(定点数)@en Fixed delta time (fixed-point)
* @returns @zh 预测的状态 @en Predicted state
*/
predict(state: TState, input: TInput, deltaTime: Fixed32): TState;
}
/**
* @zh 状态位置提取器接口
* @en State position extractor interface
*/
export interface IFixedStatePositionExtractor<TState> {
/**
* @zh 从状态中提取位置
* @en Extract position from state
*/
getPosition(state: TState): FixedVector2;
}
// =============================================================================
// 定点数客户端预测配置 | Fixed Client Prediction Config
// =============================================================================
/**
* @zh 定点数客户端预测配置
* @en Fixed-point client prediction configuration
*/
export interface FixedClientPredictionConfig {
/**
* @zh 最大未确认输入数量
* @en Maximum unacknowledged inputs
*/
maxUnacknowledgedInputs: number;
/**
* @zh 固定时间步长(定点数)
* @en Fixed delta time (fixed-point)
*/
fixedDeltaTime: Fixed32;
/**
* @zh 校正阈值(定点数,超过此值才进行校正)
* @en Reconciliation threshold (fixed-point, correction only above this value)
*/
reconciliationThreshold: Fixed32;
/**
* @zh 是否启用平滑校正(帧同步通常关闭)
* @en Enable smooth reconciliation (usually disabled for lockstep)
*/
enableSmoothReconciliation: boolean;
/**
* @zh 平滑校正速度(定点数)
* @en Smooth reconciliation speed (fixed-point)
*/
reconciliationSpeed: Fixed32;
}
// =============================================================================
// 定点数客户端预测管理器 | Fixed Client Prediction Manager
// =============================================================================
/**
* @zh 定点数客户端预测管理器
* @en Fixed-point client prediction manager
*
* @zh 提供确定性的客户端预测和服务器状态回滚校正
* @en Provides deterministic client prediction and server state rollback reconciliation
*/
export class FixedClientPrediction<TState, TInput> {
private readonly _predictor: IFixedPredictor<TState, TInput>;
private readonly _config: FixedClientPredictionConfig;
private readonly _pendingInputs: IFixedInputSnapshot<TInput>[] = [];
private _lastAcknowledgedFrame: number = 0;
private _currentFrame: number = 0;
private _lastServerState: TState | null = null;
private _predictedState: TState | null = null;
private _correctionOffset: FixedVector2 = FixedVector2.ZERO;
private _stateHistory: Map<number, TState> = new Map();
private readonly _maxHistorySize: number = 120;
constructor(
predictor: IFixedPredictor<TState, TInput>,
config?: Partial<FixedClientPredictionConfig>
) {
this._predictor = predictor;
this._config = {
maxUnacknowledgedInputs: 60,
fixedDeltaTime: Fixed32.from(1 / 60),
reconciliationThreshold: Fixed32.from(0.001),
enableSmoothReconciliation: false,
reconciliationSpeed: Fixed32.from(10),
...config
};
}
/**
* @zh 获取当前预测状态
* @en Get current predicted state
*/
get predictedState(): TState | null {
return this._predictedState;
}
/**
* @zh 获取校正偏移(用于渲染平滑)
* @en Get correction offset (for render smoothing)
*/
get correctionOffset(): FixedVector2 {
return this._correctionOffset;
}
/**
* @zh 获取待确认输入数量
* @en Get pending input count
*/
get pendingInputCount(): number {
return this._pendingInputs.length;
}
/**
* @zh 获取当前帧号
* @en Get current frame number
*/
get currentFrame(): number {
return this._currentFrame;
}
/**
* @zh 获取最后确认帧号
* @en Get last acknowledged frame
*/
get lastAcknowledgedFrame(): number {
return this._lastAcknowledgedFrame;
}
/**
* @zh 记录并预测输入
* @en Record and predict input
*
* @param input - @zh 输入数据 @en Input data
* @param currentState - @zh 当前状态 @en Current state
* @returns @zh 预测的状态 @en Predicted state
*/
recordInput(input: TInput, currentState: TState): TState {
this._currentFrame++;
const inputSnapshot: IFixedInputSnapshot<TInput> = {
frame: this._currentFrame,
input
};
this._pendingInputs.push(inputSnapshot);
while (this._pendingInputs.length > this._config.maxUnacknowledgedInputs) {
this._pendingInputs.shift();
}
this._predictedState = this._predictor.predict(
currentState,
input,
this._config.fixedDeltaTime
);
this._stateHistory.set(this._currentFrame, this._predictedState);
this._cleanupHistory();
return this._predictedState;
}
/**
* @zh 获取指定帧的输入
* @en Get input at specific frame
*/
getInputAtFrame(frame: number): IFixedInputSnapshot<TInput> | null {
return this._pendingInputs.find(i => i.frame === frame) ?? null;
}
/**
* @zh 获取所有待确认输入
* @en Get all pending inputs
*/
getPendingInputs(): readonly IFixedInputSnapshot<TInput>[] {
return this._pendingInputs;
}
/**
* @zh 处理服务器状态并进行回滚校正
* @en Process server state and perform rollback reconciliation
*
* @param serverState - @zh 服务器权威状态 @en Server authoritative state
* @param serverFrame - @zh 服务器状态对应的帧号 @en Server state frame number
* @param positionExtractor - @zh 状态位置提取器 @en State position extractor
* @returns @zh 校正后的状态 @en Reconciled state
*/
reconcile(
serverState: TState,
serverFrame: number,
positionExtractor: IFixedStatePositionExtractor<TState>
): TState {
this._lastServerState = serverState;
this._lastAcknowledgedFrame = serverFrame;
while (this._pendingInputs.length > 0 && this._pendingInputs[0].frame <= serverFrame) {
this._pendingInputs.shift();
}
const localStateAtServerFrame = this._stateHistory.get(serverFrame);
if (localStateAtServerFrame) {
const serverPos = positionExtractor.getPosition(serverState);
const localPos = positionExtractor.getPosition(localStateAtServerFrame);
const error = serverPos.sub(localPos);
const errorMagnitude = error.length();
if (errorMagnitude.gt(this._config.reconciliationThreshold)) {
if (this._config.enableSmoothReconciliation) {
const t = Fixed32.min(
Fixed32.ONE,
this._config.reconciliationSpeed.mul(this._config.fixedDeltaTime)
);
this._correctionOffset = this._correctionOffset.add(error.mul(t));
const decayRate = Fixed32.from(0.9);
this._correctionOffset = this._correctionOffset.mul(decayRate);
} else {
this._correctionOffset = FixedVector2.ZERO;
}
let state = serverState;
for (const inputSnapshot of this._pendingInputs) {
state = this._predictor.predict(
state,
inputSnapshot.input,
this._config.fixedDeltaTime
);
this._stateHistory.set(inputSnapshot.frame, state);
}
this._predictedState = state;
return state;
}
}
let state = serverState;
for (const inputSnapshot of this._pendingInputs) {
state = this._predictor.predict(
state,
inputSnapshot.input,
this._config.fixedDeltaTime
);
}
this._predictedState = state;
return state;
}
/**
* @zh 回滚到指定帧并重新模拟
* @en Rollback to specific frame and re-simulate
*
* @param targetFrame - @zh 目标帧号 @en Target frame number
* @param authoritativeState - @zh 权威状态 @en Authoritative state
* @returns @zh 重新模拟后的当前状态 @en Re-simulated current state
*/
rollbackAndResimulate(targetFrame: number, authoritativeState: TState): TState {
this._stateHistory.set(targetFrame, authoritativeState);
let state = authoritativeState;
const inputsToResimulate = this._pendingInputs.filter(i => i.frame > targetFrame);
for (const inputSnapshot of inputsToResimulate) {
state = this._predictor.predict(
state,
inputSnapshot.input,
this._config.fixedDeltaTime
);
this._stateHistory.set(inputSnapshot.frame, state);
}
this._predictedState = state;
return state;
}
/**
* @zh 获取历史状态
* @en Get historical state
*/
getStateAtFrame(frame: number): TState | null {
return this._stateHistory.get(frame) ?? null;
}
/**
* @zh 清空预测状态
* @en Clear prediction state
*/
clear(): void {
this._pendingInputs.length = 0;
this._lastAcknowledgedFrame = 0;
this._currentFrame = 0;
this._lastServerState = null;
this._predictedState = null;
this._correctionOffset = FixedVector2.ZERO;
this._stateHistory.clear();
}
private _cleanupHistory(): void {
if (this._stateHistory.size > this._maxHistorySize) {
const sortedFrames = Array.from(this._stateHistory.keys()).sort((a, b) => a - b);
const framesToRemove = sortedFrames.slice(
0,
this._stateHistory.size - this._maxHistorySize
);
for (const frame of framesToRemove) {
this._stateHistory.delete(frame);
}
}
}
}
// =============================================================================
// 工厂函数 | Factory Functions
// =============================================================================
/**
* @zh 创建定点数客户端预测管理器
* @en Create fixed-point client prediction manager
*/
export function createFixedClientPrediction<TState, TInput>(
predictor: IFixedPredictor<TState, TInput>,
config?: Partial<FixedClientPredictionConfig>
): FixedClientPrediction<TState, TInput> {
return new FixedClientPrediction(predictor, config);
}
// =============================================================================
// 预设预测器 | Preset Predictors
// =============================================================================
/**
* @zh 移动输入类型
* @en Movement input type
*/
export interface IFixedMovementInput {
/**
* @zh X方向输入 (-1, 0, 1)
* @en X direction input (-1, 0, 1)
*/
readonly dx: number;
/**
* @zh Y方向输入 (-1, 0, 1)
* @en Y direction input (-1, 0, 1)
*/
readonly dy: number;
}
/**
* @zh 移动状态类型
* @en Movement state type
*/
export interface IFixedMovementState {
/**
* @zh 位置
* @en Position
*/
readonly position: FixedVector2;
/**
* @zh 速度
* @en Velocity
*/
readonly velocity: FixedVector2;
}
/**
* @zh 创建简单移动预测器
* @en Create simple movement predictor
*
* @param speed - @zh 移动速度(定点数)@en Movement speed (fixed-point)
*/
export function createFixedMovementPredictor(
speed: Fixed32
): IFixedPredictor<IFixedMovementState, IFixedMovementInput> {
return {
predict(
state: IFixedMovementState,
input: IFixedMovementInput,
deltaTime: Fixed32
): IFixedMovementState {
const inputVec = FixedVector2.from(input.dx, input.dy);
const normalizedInput =
inputVec.lengthSquared().gt(Fixed32.ZERO) ? inputVec.normalize() : inputVec;
const velocity = normalizedInput.mul(speed);
const displacement = velocity.mul(deltaTime);
const newPosition = state.position.add(displacement);
return {
position: newPosition,
velocity
};
}
};
}
/**
* @zh 创建移动状态位置提取器
* @en Create movement state position extractor
*/
export function createFixedMovementPositionExtractor(): IFixedStatePositionExtractor<IFixedMovementState> {
return {
getPosition(state: IFixedMovementState): FixedVector2 {
return state.position;
}
};
}

View File

@@ -0,0 +1,304 @@
/**
* @zh 定点数快照缓冲区
* @en Fixed-point Snapshot Buffer
*
* @zh 用于帧同步确定性计算的快照缓冲区
* @en Snapshot buffer for deterministic lockstep calculations
*/
import { Fixed32 } from '@esengine/ecs-framework-math';
// =============================================================================
// 定点数快照接口 | Fixed Snapshot Interfaces
// =============================================================================
/**
* @zh 定点数状态快照
* @en Fixed-point state snapshot
*/
export interface IFixedStateSnapshot<T> {
/**
* @zh 帧号(定点数时间戳)
* @en Frame number (fixed-point timestamp)
*/
readonly frame: number;
/**
* @zh 状态数据
* @en State data
*/
readonly state: T;
}
/**
* @zh 定点数快照缓冲区配置
* @en Fixed-point snapshot buffer configuration
*/
export interface IFixedSnapshotBufferConfig {
/**
* @zh 最大快照数量
* @en Maximum snapshot count
*/
maxSize: number;
/**
* @zh 插值延迟帧数
* @en Interpolation delay in frames
*/
interpolationDelayFrames: number;
}
/**
* @zh 插值结果
* @en Interpolation result
*/
export interface IFixedInterpolationResult<T> {
/**
* @zh 前一个快照
* @en Previous snapshot
*/
readonly from: IFixedStateSnapshot<T>;
/**
* @zh 后一个快照
* @en Next snapshot
*/
readonly to: IFixedStateSnapshot<T>;
/**
* @zh 插值因子 (0-1)
* @en Interpolation factor (0-1)
*/
readonly t: Fixed32;
}
// =============================================================================
// 定点数快照缓冲区实现 | Fixed Snapshot Buffer Implementation
// =============================================================================
/**
* @zh 定点数快照缓冲区
* @en Fixed-point snapshot buffer
*
* @zh 使用帧号而非毫秒时间戳,确保跨平台确定性
* @en Uses frame numbers instead of millisecond timestamps for cross-platform determinism
*/
export class FixedSnapshotBuffer<T> {
private readonly _buffer: IFixedStateSnapshot<T>[] = [];
private readonly _maxSize: number;
private readonly _interpolationDelayFrames: number;
constructor(config: IFixedSnapshotBufferConfig) {
this._maxSize = config.maxSize;
this._interpolationDelayFrames = config.interpolationDelayFrames;
}
/**
* @zh 获取缓冲区大小
* @en Get buffer size
*/
get size(): number {
return this._buffer.length;
}
/**
* @zh 获取插值延迟帧数
* @en Get interpolation delay in frames
*/
get interpolationDelayFrames(): number {
return this._interpolationDelayFrames;
}
/**
* @zh 添加快照
* @en Add snapshot
*
* @param snapshot - @zh 状态快照 @en State snapshot
*/
push(snapshot: IFixedStateSnapshot<T>): void {
let insertIndex = this._buffer.length;
for (let i = this._buffer.length - 1; i >= 0; i--) {
if (this._buffer[i].frame <= snapshot.frame) {
insertIndex = i + 1;
break;
}
if (i === 0) {
insertIndex = 0;
}
}
this._buffer.splice(insertIndex, 0, snapshot);
while (this._buffer.length > this._maxSize) {
this._buffer.shift();
}
}
/**
* @zh 根据帧号获取插值快照
* @en Get interpolation snapshots by frame number
*
* @param currentFrame - @zh 当前帧号 @en Current frame number
* @returns @zh 插值结果(包含定点数插值因子)或 null @en Interpolation result with fixed-point factor or null
*/
getInterpolationSnapshots(currentFrame: number): IFixedInterpolationResult<T> | null {
if (this._buffer.length < 2) {
return null;
}
const targetFrame = currentFrame - this._interpolationDelayFrames;
for (let i = 0; i < this._buffer.length - 1; i++) {
const prev = this._buffer[i];
const next = this._buffer[i + 1];
if (prev.frame <= targetFrame && next.frame >= targetFrame) {
const duration = next.frame - prev.frame;
let t: Fixed32;
if (duration > 0) {
const elapsed = targetFrame - prev.frame;
t = Fixed32.from(elapsed).div(Fixed32.from(duration));
t = Fixed32.clamp(t, Fixed32.ZERO, Fixed32.ONE);
} else {
t = Fixed32.ZERO;
}
return { from: prev, to: next, t };
}
}
if (targetFrame > this._buffer[this._buffer.length - 1].frame) {
const prev = this._buffer[this._buffer.length - 2];
const next = this._buffer[this._buffer.length - 1];
const duration = next.frame - prev.frame;
let t: Fixed32;
if (duration > 0) {
const elapsed = targetFrame - prev.frame;
t = Fixed32.from(elapsed).div(Fixed32.from(duration));
t = Fixed32.min(t, Fixed32.from(2));
} else {
t = Fixed32.ONE;
}
return { from: prev, to: next, t };
}
return null;
}
/**
* @zh 根据精确帧时间获取插值快照(支持子帧插值)
* @en Get interpolation snapshots by precise frame time (supports sub-frame interpolation)
*
* @param frameTime - @zh 精确帧时间(定点数)@en Precise frame time (fixed-point)
* @returns @zh 插值结果或 null @en Interpolation result or null
*/
getInterpolationSnapshotsFixed(frameTime: Fixed32): IFixedInterpolationResult<T> | null {
if (this._buffer.length < 2) {
return null;
}
const targetFrame = frameTime.sub(Fixed32.from(this._interpolationDelayFrames));
for (let i = 0; i < this._buffer.length - 1; i++) {
const prev = this._buffer[i];
const next = this._buffer[i + 1];
const prevFrame = Fixed32.from(prev.frame);
const nextFrame = Fixed32.from(next.frame);
if (prevFrame.le(targetFrame) && nextFrame.ge(targetFrame)) {
const duration = nextFrame.sub(prevFrame);
let t: Fixed32;
if (duration.gt(Fixed32.ZERO)) {
t = targetFrame.sub(prevFrame).div(duration);
t = Fixed32.clamp(t, Fixed32.ZERO, Fixed32.ONE);
} else {
t = Fixed32.ZERO;
}
return { from: prev, to: next, t };
}
}
const lastFrame = Fixed32.from(this._buffer[this._buffer.length - 1].frame);
if (targetFrame.gt(lastFrame)) {
const prev = this._buffer[this._buffer.length - 2];
const next = this._buffer[this._buffer.length - 1];
const prevFrame = Fixed32.from(prev.frame);
const nextFrame = Fixed32.from(next.frame);
const duration = nextFrame.sub(prevFrame);
let t: Fixed32;
if (duration.gt(Fixed32.ZERO)) {
t = targetFrame.sub(prevFrame).div(duration);
t = Fixed32.min(t, Fixed32.from(2));
} else {
t = Fixed32.ONE;
}
return { from: prev, to: next, t };
}
return null;
}
/**
* @zh 获取最新快照
* @en Get latest snapshot
*/
getLatest(): IFixedStateSnapshot<T> | null {
return this._buffer.length > 0 ? this._buffer[this._buffer.length - 1] : null;
}
/**
* @zh 获取特定帧号的快照
* @en Get snapshot at specific frame
*/
getAtFrame(frame: number): IFixedStateSnapshot<T> | null {
for (const snapshot of this._buffer) {
if (snapshot.frame === frame) {
return snapshot;
}
}
return null;
}
/**
* @zh 获取特定帧号之后的所有快照
* @en Get all snapshots after specific frame
*/
getSnapshotsAfter(frame: number): IFixedStateSnapshot<T>[] {
return this._buffer.filter(s => s.frame > frame);
}
/**
* @zh 移除指定帧号之前的所有快照
* @en Remove all snapshots before specific frame
*/
removeSnapshotsBefore(frame: number): void {
while (this._buffer.length > 0 && this._buffer[0].frame < frame) {
this._buffer.shift();
}
}
/**
* @zh 清空缓冲区
* @en Clear buffer
*/
clear(): void {
this._buffer.length = 0;
}
}
// =============================================================================
// 工厂函数 | Factory Functions
// =============================================================================
/**
* @zh 创建定点数快照缓冲区
* @en Create fixed-point snapshot buffer
*
* @param maxSize - @zh 最大快照数量(默认 30@en Maximum snapshot count (default 30)
* @param interpolationDelayFrames - @zh 插值延迟帧数(默认 2@en Interpolation delay frames (default 2)
*/
export function createFixedSnapshotBuffer<T>(
maxSize: number = 30,
interpolationDelayFrames: number = 2
): FixedSnapshotBuffer<T> {
return new FixedSnapshotBuffer<T>({ maxSize, interpolationDelayFrames });
}

View File

@@ -0,0 +1,229 @@
/**
* @zh 定点数变换插值器
* @en Fixed-point Transform Interpolator
*
* @zh 用于帧同步确定性计算的插值器
* @en Interpolator for deterministic lockstep calculations
*/
import { Fixed32, FixedVector2, FixedMath } from '@esengine/ecs-framework-math';
import {
FixedTransformState,
FixedTransformStateWithVelocity,
type IFixedTransformStateRaw,
type IFixedTransformStateWithVelocityRaw
} from './FixedTransformState';
// =============================================================================
// 插值器接口 | Interpolator Interface
// =============================================================================
/**
* @zh 定点数插值器接口
* @en Fixed-point interpolator interface
*/
export interface IFixedInterpolator<T> {
/**
* @zh 在两个状态之间插值
* @en Interpolate between two states
* @param from - @zh 起始状态 @en Start state
* @param to - @zh 结束状态 @en End state
* @param t - @zh 插值因子 (0-1) @en Interpolation factor (0-1)
*/
interpolate(from: T, to: T, t: Fixed32): T;
}
/**
* @zh 定点数外推器接口
* @en Fixed-point extrapolator interface
*/
export interface IFixedExtrapolator<T> {
/**
* @zh 基于速度外推状态
* @en Extrapolate state based on velocity
* @param state - @zh 当前状态 @en Current state
* @param deltaTime - @zh 时间增量 @en Time delta
*/
extrapolate(state: T, deltaTime: Fixed32): T;
}
// =============================================================================
// 定点数变换插值器 | Fixed Transform Interpolator
// =============================================================================
/**
* @zh 定点数变换状态插值器
* @en Fixed-point transform state interpolator
*/
export class FixedTransformInterpolator
implements IFixedInterpolator<FixedTransformState>, IFixedExtrapolator<FixedTransformStateWithVelocity> {
/**
* @zh 在两个变换状态之间插值
* @en Interpolate between two transform states
*/
interpolate(from: FixedTransformState, to: FixedTransformState, t: Fixed32): FixedTransformState {
return new FixedTransformState(
from.position.lerp(to.position, t),
FixedMath.lerpAngle(from.rotation, to.rotation, t)
);
}
/**
* @zh 基于速度外推变换状态
* @en Extrapolate transform state based on velocity
*/
extrapolate(
state: FixedTransformStateWithVelocity,
deltaTime: Fixed32
): FixedTransformStateWithVelocity {
return new FixedTransformStateWithVelocity(
state.position.add(state.velocity.mul(deltaTime)),
state.rotation.add(state.angularVelocity.mul(deltaTime)),
state.velocity,
state.angularVelocity
);
}
/**
* @zh 使用原始值进行插值
* @en Interpolate using raw values
*/
interpolateRaw(
from: IFixedTransformStateRaw,
to: IFixedTransformStateRaw,
t: number
): IFixedTransformStateRaw {
const fromState = FixedTransformState.fromRaw(from);
const toState = FixedTransformState.fromRaw(to);
const tFixed = Fixed32.from(t);
return this.interpolate(fromState, toState, tFixed).toRaw();
}
/**
* @zh 使用原始值进行外推
* @en Extrapolate using raw values
*/
extrapolateRaw(
state: IFixedTransformStateWithVelocityRaw,
deltaTimeMs: number
): IFixedTransformStateWithVelocityRaw {
const fixedState = FixedTransformStateWithVelocity.fromRaw(state);
const deltaTime = Fixed32.from(deltaTimeMs / 1000); // ms to seconds
return this.extrapolate(fixedState, deltaTime).toRaw();
}
}
// =============================================================================
// 赫尔米特插值器 | Hermite Interpolator
// =============================================================================
/**
* @zh 定点数赫尔米特变换插值器(更平滑的曲线)
* @en Fixed-point Hermite transform interpolator (smoother curves)
*/
export class FixedHermiteTransformInterpolator
implements IFixedInterpolator<FixedTransformStateWithVelocity> {
/**
* @zh 快照间隔时间(秒)
* @en Snapshot interval in seconds
*/
private readonly snapshotInterval: Fixed32;
constructor(snapshotIntervalMs: number = 100) {
this.snapshotInterval = Fixed32.from(snapshotIntervalMs / 1000);
}
/**
* @zh 使用赫尔米特插值
* @en Use Hermite interpolation
*/
interpolate(
from: FixedTransformStateWithVelocity,
to: FixedTransformStateWithVelocity,
t: Fixed32
): FixedTransformStateWithVelocity {
const t2 = t.mul(t);
const t3 = t2.mul(t);
const two = Fixed32.from(2);
const three = Fixed32.from(3);
const six = Fixed32.from(6);
const four = Fixed32.from(4);
// Hermite basis functions
// h00 = 2t³ - 3t² + 1
const h00 = two.mul(t3).sub(three.mul(t2)).add(Fixed32.ONE);
// h10 = t³ - 2t² + t
const h10 = t3.sub(two.mul(t2)).add(t);
// h01 = -2t³ + 3t²
const h01 = two.neg().mul(t3).add(three.mul(t2));
// h11 = t³ - t²
const h11 = t3.sub(t2);
const dt = this.snapshotInterval;
// Position interpolation
const x = h00.mul(from.position.x)
.add(h10.mul(from.velocity.x).mul(dt))
.add(h01.mul(to.position.x))
.add(h11.mul(to.velocity.x).mul(dt));
const y = h00.mul(from.position.y)
.add(h10.mul(from.velocity.y).mul(dt))
.add(h01.mul(to.position.y))
.add(h11.mul(to.velocity.y).mul(dt));
// Velocity derivatives
// dh00 = 6t² - 6t
const dh00 = six.mul(t2).sub(six.mul(t));
// dh10 = 3t² - 4t + 1
const dh10 = three.mul(t2).sub(four.mul(t)).add(Fixed32.ONE);
// dh01 = -6t² + 6t
const dh01 = six.neg().mul(t2).add(six.mul(t));
// dh11 = 3t² - 2t
const dh11 = three.mul(t2).sub(two.mul(t));
const velocityX = dh00.mul(from.position.x)
.add(dh10.mul(from.velocity.x).mul(dt))
.add(dh01.mul(to.position.x))
.add(dh11.mul(to.velocity.x).mul(dt))
.div(dt);
const velocityY = dh00.mul(from.position.y)
.add(dh10.mul(from.velocity.y).mul(dt))
.add(dh01.mul(to.position.y))
.add(dh11.mul(to.velocity.y).mul(dt))
.div(dt);
return new FixedTransformStateWithVelocity(
new FixedVector2(x, y),
FixedMath.lerpAngle(from.rotation, to.rotation, t),
new FixedVector2(velocityX, velocityY),
Fixed32.lerp(from.angularVelocity, to.angularVelocity, t)
);
}
}
// =============================================================================
// 工厂函数 | Factory Functions
// =============================================================================
/**
* @zh 创建定点数变换插值器
* @en Create fixed-point transform interpolator
*/
export function createFixedTransformInterpolator(): FixedTransformInterpolator {
return new FixedTransformInterpolator();
}
/**
* @zh 创建定点数赫尔米特变换插值器
* @en Create fixed-point Hermite transform interpolator
*/
export function createFixedHermiteTransformInterpolator(
snapshotIntervalMs?: number
): FixedHermiteTransformInterpolator {
return new FixedHermiteTransformInterpolator(snapshotIntervalMs);
}

View File

@@ -0,0 +1,265 @@
/**
* @zh 定点数变换状态
* @en Fixed-point Transform State
*
* @zh 用于帧同步确定性计算的变换状态
* @en Transform state for deterministic lockstep calculations
*/
import { Fixed32, FixedVector2 } from '@esengine/ecs-framework-math';
// =============================================================================
// 定点数变换状态接口 | Fixed Transform State Interface
// =============================================================================
/**
* @zh 定点数变换状态(原始值)
* @en Fixed-point transform state (raw values)
*
* @zh 用于网络传输的原始整数格式,确保跨平台一致性
* @en Raw integer format for network transmission, ensures cross-platform consistency
*/
export interface IFixedTransformStateRaw {
/**
* @zh X 坐标原始值
* @en X coordinate raw value
*/
x: number;
/**
* @zh Y 坐标原始值
* @en Y coordinate raw value
*/
y: number;
/**
* @zh 旋转角度原始值(弧度 * 65536
* @en Rotation raw value (radians * 65536)
*/
rotation: number;
}
/**
* @zh 带速度的定点数变换状态(原始值)
* @en Fixed-point transform state with velocity (raw values)
*/
export interface IFixedTransformStateWithVelocityRaw extends IFixedTransformStateRaw {
/**
* @zh X 速度原始值
* @en X velocity raw value
*/
velocityX: number;
/**
* @zh Y 速度原始值
* @en Y velocity raw value
*/
velocityY: number;
/**
* @zh 角速度原始值
* @en Angular velocity raw value
*/
angularVelocity: number;
}
// =============================================================================
// 定点数变换状态类 | Fixed Transform State Class
// =============================================================================
/**
* @zh 定点数变换状态
* @en Fixed-point transform state
*/
export class FixedTransformState {
readonly position: FixedVector2;
readonly rotation: Fixed32;
constructor(position: FixedVector2, rotation: Fixed32) {
this.position = position;
this.rotation = rotation;
}
/**
* @zh 从原始值创建
* @en Create from raw values
*/
static fromRaw(raw: IFixedTransformStateRaw): FixedTransformState {
return new FixedTransformState(
FixedVector2.fromRaw(raw.x, raw.y),
Fixed32.fromRaw(raw.rotation)
);
}
/**
* @zh 从浮点数创建
* @en Create from floating-point numbers
*/
static from(x: number, y: number, rotation: number): FixedTransformState {
return new FixedTransformState(
FixedVector2.from(x, y),
Fixed32.from(rotation)
);
}
/**
* @zh 转换为原始值(用于网络传输)
* @en Convert to raw values (for network transmission)
*/
toRaw(): IFixedTransformStateRaw {
return {
x: this.position.x.toRaw(),
y: this.position.y.toRaw(),
rotation: this.rotation.toRaw()
};
}
/**
* @zh 转换为浮点数对象(用于渲染)
* @en Convert to floating-point object (for rendering)
*/
toFloat(): { x: number; y: number; rotation: number } {
return {
x: this.position.x.toNumber(),
y: this.position.y.toNumber(),
rotation: this.rotation.toNumber()
};
}
/**
* @zh 检查是否相等
* @en Check equality
*/
equals(other: FixedTransformState): boolean {
return this.position.equals(other.position) && this.rotation.eq(other.rotation);
}
}
/**
* @zh 带速度的定点数变换状态
* @en Fixed-point transform state with velocity
*/
export class FixedTransformStateWithVelocity {
readonly position: FixedVector2;
readonly rotation: Fixed32;
readonly velocity: FixedVector2;
readonly angularVelocity: Fixed32;
constructor(
position: FixedVector2,
rotation: Fixed32,
velocity: FixedVector2,
angularVelocity: Fixed32
) {
this.position = position;
this.rotation = rotation;
this.velocity = velocity;
this.angularVelocity = angularVelocity;
}
/**
* @zh 从原始值创建
* @en Create from raw values
*/
static fromRaw(raw: IFixedTransformStateWithVelocityRaw): FixedTransformStateWithVelocity {
return new FixedTransformStateWithVelocity(
FixedVector2.fromRaw(raw.x, raw.y),
Fixed32.fromRaw(raw.rotation),
FixedVector2.fromRaw(raw.velocityX, raw.velocityY),
Fixed32.fromRaw(raw.angularVelocity)
);
}
/**
* @zh 从浮点数创建
* @en Create from floating-point numbers
*/
static from(
x: number,
y: number,
rotation: number,
velocityX: number,
velocityY: number,
angularVelocity: number
): FixedTransformStateWithVelocity {
return new FixedTransformStateWithVelocity(
FixedVector2.from(x, y),
Fixed32.from(rotation),
FixedVector2.from(velocityX, velocityY),
Fixed32.from(angularVelocity)
);
}
/**
* @zh 转换为原始值
* @en Convert to raw values
*/
toRaw(): IFixedTransformStateWithVelocityRaw {
return {
x: this.position.x.toRaw(),
y: this.position.y.toRaw(),
rotation: this.rotation.toRaw(),
velocityX: this.velocity.x.toRaw(),
velocityY: this.velocity.y.toRaw(),
angularVelocity: this.angularVelocity.toRaw()
};
}
/**
* @zh 转换为浮点数对象
* @en Convert to floating-point object
*/
toFloat(): {
x: number;
y: number;
rotation: number;
velocityX: number;
velocityY: number;
angularVelocity: number;
} {
return {
x: this.position.x.toNumber(),
y: this.position.y.toNumber(),
rotation: this.rotation.toNumber(),
velocityX: this.velocity.x.toNumber(),
velocityY: this.velocity.y.toNumber(),
angularVelocity: this.angularVelocity.toNumber()
};
}
/**
* @zh 检查是否相等
* @en Check equality
*/
equals(other: FixedTransformStateWithVelocity): boolean {
return this.position.equals(other.position) &&
this.rotation.eq(other.rotation) &&
this.velocity.equals(other.velocity) &&
this.angularVelocity.eq(other.angularVelocity);
}
}
// =============================================================================
// 工具函数 | Utility Functions
// =============================================================================
/**
* @zh 创建零状态
* @en Create zero state
*/
export function createZeroFixedTransformState(): FixedTransformState {
return new FixedTransformState(FixedVector2.ZERO, Fixed32.ZERO);
}
/**
* @zh 创建带速度的零状态
* @en Create zero state with velocity
*/
export function createZeroFixedTransformStateWithVelocity(): FixedTransformStateWithVelocity {
return new FixedTransformStateWithVelocity(
FixedVector2.ZERO,
Fixed32.ZERO,
FixedVector2.ZERO,
Fixed32.ZERO
);
}

View File

@@ -0,0 +1,63 @@
/**
* @zh 定点数网络同步模块
* @en Fixed-point network sync module
*
* @zh 用于帧同步确定性计算的网络同步类型和工具
* @en Network sync types and utilities for deterministic lockstep calculations
*/
// =============================================================================
// 变换状态 | Transform State
// =============================================================================
export {
FixedTransformState,
FixedTransformStateWithVelocity,
createZeroFixedTransformState,
createZeroFixedTransformStateWithVelocity,
type IFixedTransformStateRaw,
type IFixedTransformStateWithVelocityRaw,
} from './FixedTransformState';
// =============================================================================
// 插值器 | Interpolators
// =============================================================================
export {
FixedTransformInterpolator,
FixedHermiteTransformInterpolator,
createFixedTransformInterpolator,
createFixedHermiteTransformInterpolator,
type IFixedInterpolator,
type IFixedExtrapolator,
} from './FixedTransformInterpolator';
// =============================================================================
// 快照缓冲区 | Snapshot Buffer
// =============================================================================
export {
FixedSnapshotBuffer,
createFixedSnapshotBuffer,
type IFixedStateSnapshot,
type IFixedSnapshotBufferConfig,
type IFixedInterpolationResult,
} from './FixedSnapshotBuffer';
// =============================================================================
// 客户端预测 | Client Prediction
// =============================================================================
export {
FixedClientPrediction,
createFixedClientPrediction,
createFixedMovementPredictor,
createFixedMovementPositionExtractor,
type IFixedInputSnapshot,
type IFixedPredictedState,
type IFixedPredictor,
type IFixedStatePositionExtractor,
type FixedClientPredictionConfig,
type IFixedMovementInput,
type IFixedMovementState,
} from './FixedClientPrediction';

View File

@@ -78,3 +78,49 @@ export {
ComponentSyncSystem,
createComponentSyncSystem
} from './ComponentSync';
// =============================================================================
// 定点数同步 | Fixed-point Sync (Deterministic Lockstep)
// =============================================================================
export {
// Transform State
FixedTransformState,
FixedTransformStateWithVelocity,
createZeroFixedTransformState,
createZeroFixedTransformStateWithVelocity,
// Interpolators
FixedTransformInterpolator,
FixedHermiteTransformInterpolator,
createFixedTransformInterpolator,
createFixedHermiteTransformInterpolator,
// Snapshot Buffer
FixedSnapshotBuffer,
createFixedSnapshotBuffer,
// Client Prediction
FixedClientPrediction,
createFixedClientPrediction,
createFixedMovementPredictor,
createFixedMovementPositionExtractor,
} from './fixed';
export type {
// Transform State Types
IFixedTransformStateRaw,
IFixedTransformStateWithVelocityRaw,
// Interpolator Types
IFixedInterpolator,
IFixedExtrapolator,
// Snapshot Buffer Types
IFixedStateSnapshot,
IFixedSnapshotBufferConfig,
IFixedInterpolationResult,
// Client Prediction Types
IFixedInputSnapshot,
IFixedPredictedState,
IFixedPredictor,
IFixedStatePositionExtractor,
FixedClientPredictionConfig,
IFixedMovementInput,
IFixedMovementState,
} from './fixed';

View File

@@ -1,5 +1,41 @@
# @esengine/pathfinding
## 12.0.0
### Patch Changes
- Updated dependencies [[`4e66bd8`](https://github.com/esengine/esengine/commit/4e66bd8e2be80b366a7723dcc48b99df0457aed4)]:
- @esengine/blueprint@4.5.0
- @esengine/ecs-framework-math@2.10.1
## 11.0.0
### Patch Changes
- Updated dependencies [[`fa593a3`](https://github.com/esengine/esengine/commit/fa593a3c69292207800750f8106f418465cb7c0f)]:
- @esengine/ecs-framework-math@2.10.0
## 10.0.0
### Patch Changes
- Updated dependencies [[`bffe90b`](https://github.com/esengine/esengine/commit/bffe90b6a17563cc90709faf339b229dc3abd22d)]:
- @esengine/ecs-framework-math@2.9.0
## 9.0.0
### Patch Changes
- Updated dependencies [[`30173f0`](https://github.com/esengine/esengine/commit/30173f076415c9770a429b236b8bab95a2fdc498)]:
- @esengine/ecs-framework-math@2.8.0
## 8.0.0
### Patch Changes
- Updated dependencies [[`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b)]:
- @esengine/blueprint@4.4.0
## 7.0.0
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/pathfinding",
"version": "7.0.0",
"version": "12.0.0",
"description": "寻路系统 | Pathfinding System - A*, Grid, NavMesh",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1,5 +1,19 @@
# @esengine/procgen
## 9.0.0
### Patch Changes
- Updated dependencies [[`4e66bd8`](https://github.com/esengine/esengine/commit/4e66bd8e2be80b366a7723dcc48b99df0457aed4)]:
- @esengine/blueprint@4.5.0
## 8.0.0
### Patch Changes
- Updated dependencies [[`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b)]:
- @esengine/blueprint@4.4.0
## 7.0.0
### Patch Changes

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