Compare commits
62 Commits
@esengine/
...
@esengine/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e90a42b1c9 | ||
|
|
30173f0764 | ||
|
|
12da6bd609 | ||
|
|
6b5b4efa72 | ||
|
|
51334dfc50 | ||
|
|
2035355e22 | ||
|
|
9e5f037d5d | ||
|
|
43be62b4cb | ||
|
|
c902dd7291 | ||
|
|
0d33cf0097 | ||
|
|
45de62e453 | ||
|
|
b983cbf87a | ||
|
|
34583b23af | ||
|
|
f2c3a24404 | ||
|
|
3bfb8a1c9b | ||
|
|
2ee8d87647 | ||
|
|
2d537dc10c | ||
|
|
c2acd14fce | ||
|
|
7f631793d4 | ||
|
|
2e84942ea1 | ||
|
|
d0057333a7 | ||
|
|
54c8ff4d8f | ||
|
|
caf3be72cd | ||
|
|
ec3e449681 | ||
|
|
b95a46edaf | ||
|
|
f493f2d6cc | ||
|
|
6970394717 | ||
|
|
0e4b66aac4 | ||
|
|
7399e91a5b | ||
|
|
c84addaa0b | ||
|
|
61da38faf5 | ||
|
|
f333b81298 | ||
|
|
69bb6bd946 | ||
|
|
3b6fc8266f | ||
|
|
db22bd3028 | ||
|
|
b80e967829 | ||
|
|
9e87eb39b9 | ||
|
|
ff549f3c2a | ||
|
|
15c1d98305 | ||
|
|
4a3d8c3962 | ||
|
|
0f5aa633d8 | ||
|
|
85171a0a5c | ||
|
|
35d81880a7 | ||
|
|
71022abc99 | ||
|
|
87f71e2251 | ||
|
|
b9ea8d14cf | ||
|
|
10d0fb1d5c | ||
|
|
71e111415f | ||
|
|
0de45279e6 | ||
|
|
cc6f12d470 | ||
|
|
902c0a1074 | ||
|
|
d3e489aad3 | ||
|
|
12051d987f | ||
|
|
b38fe5ebf4 | ||
|
|
f01ce1e320 | ||
|
|
094133a71a | ||
|
|
3e5b7783be | ||
|
|
ebcb4d00a8 | ||
|
|
d2af9caae9 | ||
|
|
bb696c6a60 | ||
|
|
ffd35a71cd | ||
|
|
1f3a76aabe |
@@ -49,7 +49,6 @@
|
||||
"@esengine/material-editor",
|
||||
"@esengine/shader-editor",
|
||||
"@esengine/world-streaming-editor",
|
||||
"@esengine/node-editor",
|
||||
"@esengine/sdk",
|
||||
"@esengine/worker-generator",
|
||||
"@esengine/engine"
|
||||
|
||||
4
.github/workflows/release-changesets.yml
vendored
4
.github/workflows/release-changesets.yml
vendored
@@ -57,8 +57,12 @@ jobs:
|
||||
pnpm --filter "@esengine/rpc" build
|
||||
pnpm --filter "@esengine/network" build
|
||||
pnpm --filter "@esengine/server" build
|
||||
pnpm --filter "@esengine/database-drivers" build
|
||||
pnpm --filter "@esengine/database" build
|
||||
pnpm --filter "@esengine/transaction" build
|
||||
pnpm --filter "@esengine/cli" build
|
||||
pnpm --filter "create-esengine-server" build
|
||||
pnpm --filter "@esengine/node-editor" build
|
||||
|
||||
- name: Create Release Pull Request or Publish
|
||||
id: changesets
|
||||
|
||||
@@ -3,8 +3,12 @@ import { defineConfig } from 'astro/config';
|
||||
import starlight from '@astrojs/starlight';
|
||||
import vue from '@astrojs/vue';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
||||
export default defineConfig({
|
||||
markdown: {
|
||||
rehypePlugins: [rehypeRaw],
|
||||
},
|
||||
integrations: [
|
||||
starlight({
|
||||
title: 'ESEngine',
|
||||
@@ -96,6 +100,8 @@ export default defineConfig({
|
||||
{ label: '最佳实践', slug: 'guide/scene/best-practices', translations: { en: 'Best Practices' } },
|
||||
],
|
||||
},
|
||||
{ label: '场景管理器', slug: 'guide/scene-manager', translations: { en: 'SceneManager' } },
|
||||
{ label: '持久实体', slug: 'guide/persistent-entity', translations: { en: 'Persistent Entity' } },
|
||||
{
|
||||
label: '序列化',
|
||||
translations: { en: 'Serialization' },
|
||||
@@ -232,6 +238,8 @@ export default defineConfig({
|
||||
translations: { en: 'Blueprint' },
|
||||
items: [
|
||||
{ label: '概述', slug: 'modules/blueprint', translations: { en: 'Overview' } },
|
||||
{ label: '编辑器使用指南', slug: 'modules/blueprint/editor-guide', translations: { en: 'Editor Guide' } },
|
||||
{ label: 'Cocos Creator 编辑器', slug: 'modules/blueprint/cocos-editor', translations: { en: 'Cocos Creator Editor' } },
|
||||
{ label: '虚拟机 API', slug: 'modules/blueprint/vm', translations: { en: 'VM API' } },
|
||||
{ label: '自定义节点', slug: 'modules/blueprint/custom-nodes', translations: { en: 'Custom Nodes' } },
|
||||
{ label: '内置节点', slug: 'modules/blueprint/nodes', translations: { en: 'Built-in Nodes' } },
|
||||
@@ -267,9 +275,12 @@ export default defineConfig({
|
||||
{ label: '概述', slug: 'modules/network', translations: { en: 'Overview' } },
|
||||
{ label: '客户端', slug: 'modules/network/client', translations: { en: 'Client' } },
|
||||
{ label: '服务器', slug: 'modules/network/server', translations: { en: 'Server' } },
|
||||
{ label: 'HTTP 路由', slug: 'modules/network/http', translations: { en: 'HTTP Routing' } },
|
||||
{ label: '认证系统', slug: 'modules/network/auth', translations: { en: 'Authentication' } },
|
||||
{ label: '速率限制', slug: 'modules/network/rate-limit', translations: { en: 'Rate Limiting' } },
|
||||
{ label: '分布式房间', slug: 'modules/network/distributed', translations: { en: 'Distributed Rooms' } },
|
||||
{ label: '状态同步', slug: 'modules/network/sync', translations: { en: 'State Sync' } },
|
||||
{ label: '定点数同步', slug: 'modules/network/fixed-point', translations: { en: 'Fixed-Point Sync' } },
|
||||
{ label: '客户端预测', slug: 'modules/network/prediction', translations: { en: 'Prediction' } },
|
||||
{ label: 'AOI 兴趣区域', slug: 'modules/network/aoi', translations: { en: 'AOI' } },
|
||||
{ label: '增量压缩', slug: 'modules/network/delta', translations: { en: 'Delta Compression' } },
|
||||
@@ -287,6 +298,25 @@ export default defineConfig({
|
||||
{ label: '分布式事务', slug: 'modules/transaction/distributed', translations: { en: 'Distributed' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '数据库',
|
||||
translations: { en: 'Database' },
|
||||
items: [
|
||||
{ label: '概述', slug: 'modules/database', translations: { en: 'Overview' } },
|
||||
{ label: '仓储模式', slug: 'modules/database/repository', translations: { en: 'Repository' } },
|
||||
{ label: '用户仓储', slug: 'modules/database/user', translations: { en: 'User Repository' } },
|
||||
{ label: '查询构建器', slug: 'modules/database/query', translations: { en: 'Query Builder' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '数据库驱动',
|
||||
translations: { en: 'Database Drivers' },
|
||||
items: [
|
||||
{ label: '概述', slug: 'modules/database-drivers', translations: { en: 'Overview' } },
|
||||
{ label: 'MongoDB', slug: 'modules/database-drivers/mongo', translations: { en: 'MongoDB' } },
|
||||
{ label: 'Redis', slug: 'modules/database-drivers/redis', translations: { en: 'Redis' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '世界流式加载',
|
||||
translations: { en: 'World Streaming' },
|
||||
|
||||
@@ -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"
|
||||
|
||||
462
docs/public/js/blueprint-graph.js
Normal file
462
docs/public/js/blueprint-graph.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -71,6 +71,55 @@ class ConfiguredScene extends Scene {
|
||||
}
|
||||
```
|
||||
|
||||
## Runtime Environment
|
||||
|
||||
For networked games, you can configure the runtime environment to distinguish between server and client logic.
|
||||
|
||||
### Global Configuration (Recommended)
|
||||
|
||||
Set the runtime environment once at the Core level - all Scenes will inherit this setting:
|
||||
|
||||
```typescript
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
|
||||
// Method 1: Set in Core.create()
|
||||
Core.create({ runtimeEnvironment: 'server' });
|
||||
|
||||
// Method 2: Set static property directly
|
||||
Core.runtimeEnvironment = 'server';
|
||||
```
|
||||
|
||||
### Per-Scene Override
|
||||
|
||||
Individual scenes can override the global setting:
|
||||
|
||||
```typescript
|
||||
const clientScene = new Scene({ runtimeEnvironment: 'client' });
|
||||
```
|
||||
|
||||
### Environment Types
|
||||
|
||||
| Environment | Use Case |
|
||||
|-------------|----------|
|
||||
| `'standalone'` | Single-player games (default) |
|
||||
| `'server'` | Game server, authoritative logic |
|
||||
| `'client'` | Game client, rendering/input |
|
||||
|
||||
### Checking Environment in Systems
|
||||
|
||||
```typescript
|
||||
class CollectibleSpawnSystem extends EntitySystem {
|
||||
private checkCollections(): void {
|
||||
// Skip on client - only server handles authoritative logic
|
||||
if (!this.scene.isServer) return;
|
||||
|
||||
// Server-authoritative spawn logic...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [System Runtime Decorators](/en/guide/system/index#runtime-environment-decorators) for decorator-based approach.
|
||||
|
||||
### Running a Scene
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -160,6 +160,53 @@ scene.addSystem(new SystemA()); // addOrder = 0, executes first
|
||||
scene.addSystem(new SystemB()); // addOrder = 1, executes second
|
||||
```
|
||||
|
||||
## Runtime Environment Decorators
|
||||
|
||||
For networked games, you can use decorators to control which environment a system method runs in.
|
||||
|
||||
### Available Decorators
|
||||
|
||||
| Decorator | Effect |
|
||||
|-----------|--------|
|
||||
| `@ServerOnly()` | Method only executes on server |
|
||||
| `@ClientOnly()` | Method only executes on client |
|
||||
| `@NotServer()` | Method skipped on server |
|
||||
| `@NotClient()` | Method skipped on client |
|
||||
|
||||
### Usage Example
|
||||
|
||||
```typescript
|
||||
import { EntitySystem, ServerOnly, ClientOnly } from '@esengine/ecs-framework';
|
||||
|
||||
class GameSystem extends EntitySystem {
|
||||
@ServerOnly()
|
||||
private spawnEnemies(): void {
|
||||
// Only runs on server - authoritative spawn logic
|
||||
}
|
||||
|
||||
@ClientOnly()
|
||||
private playEffects(): void {
|
||||
// Only runs on client - visual effects
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Simple Conditional Check
|
||||
|
||||
For simple cases, a direct check is often clearer than decorators:
|
||||
|
||||
```typescript
|
||||
class CollectibleSystem extends EntitySystem {
|
||||
private checkCollections(): void {
|
||||
if (!this.scene.isServer) return; // Skip on client
|
||||
|
||||
// Server-authoritative logic...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [Scene Runtime Environment](/en/guide/scene/index#runtime-environment) for configuration details.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [System Types](/en/guide/system/types) - Learn about different system base classes
|
||||
|
||||
@@ -182,6 +182,70 @@ export class IsHealthLow implements INodeExecutor {
|
||||
}
|
||||
```
|
||||
|
||||
## Using Custom Executors in BehaviorTreeBuilder
|
||||
|
||||
After defining a custom executor with `@NodeExecutorMetadata`, use the `.action()` method in the builder:
|
||||
|
||||
```typescript
|
||||
import { BehaviorTreeBuilder, BehaviorTreeStarter } from '@esengine/behavior-tree';
|
||||
|
||||
// Use custom executor in behavior tree
|
||||
const tree = BehaviorTreeBuilder.create('CombatAI')
|
||||
.defineBlackboardVariable('health', 100)
|
||||
.defineBlackboardVariable('target', null)
|
||||
.selector('Root')
|
||||
.sequence('AttackSequence')
|
||||
// Use custom action - matches implementationType in decorator
|
||||
.action('AttackAction', 'Attack', { damage: 25 })
|
||||
.action('MoveToTarget', 'Chase')
|
||||
.end()
|
||||
.action('WaitAction', 'Idle', { duration: 1000 })
|
||||
.end()
|
||||
.build();
|
||||
|
||||
// Start the behavior tree
|
||||
const entity = scene.createEntity('Enemy');
|
||||
BehaviorTreeStarter.start(entity, tree);
|
||||
```
|
||||
|
||||
### Builder Methods for Custom Nodes
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `.action(type, name?, config?)` | Add custom action node |
|
||||
| `.condition(type, name?, config?)` | Add custom condition node |
|
||||
| `.executeAction(name)` | Use blackboard function `action_{name}` |
|
||||
| `.executeCondition(name)` | Use blackboard function `condition_{name}` |
|
||||
|
||||
### Complete Example
|
||||
|
||||
```typescript
|
||||
// 1. Define custom executor
|
||||
@NodeExecutorMetadata({
|
||||
implementationType: 'AttackAction',
|
||||
nodeType: NodeType.Action,
|
||||
displayName: 'Attack',
|
||||
category: 'Combat',
|
||||
configSchema: {
|
||||
damage: { type: 'number', default: 10, supportBinding: true }
|
||||
}
|
||||
})
|
||||
class AttackAction implements INodeExecutor {
|
||||
execute(context: NodeExecutionContext): TaskStatus {
|
||||
const damage = BindingHelper.getValue<number>(context, 'damage', 10);
|
||||
console.log(`Attacking with ${damage} damage!`);
|
||||
return TaskStatus.Success;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Build and use
|
||||
const tree = BehaviorTreeBuilder.create('AI')
|
||||
.selector('Root')
|
||||
.action('AttackAction', 'Attack', { damage: 50 })
|
||||
.end()
|
||||
.build();
|
||||
```
|
||||
|
||||
## Registering Custom Executors
|
||||
|
||||
Executors are auto-registered via the decorator. To manually register:
|
||||
|
||||
@@ -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
|
||||
|
||||
383
docs/src/content/docs/en/modules/blueprint/cocos-editor.md
Normal file
383
docs/src/content/docs/en/modules/blueprint/cocos-editor.md
Normal file
@@ -0,0 +1,383 @@
|
||||
---
|
||||
title: "Cocos Creator Blueprint Editor"
|
||||
description: "Using the blueprint visual scripting system in Cocos Creator"
|
||||
---
|
||||
|
||||
This document explains how to install and use the blueprint visual scripting editor extension in Cocos Creator projects.
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Copy Extension to Project
|
||||
|
||||
Copy the `cocos-node-editor` extension to your Cocos Creator project's `extensions` directory:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── assets/
|
||||
├── extensions/
|
||||
│ └── cocos-node-editor/ # Blueprint editor extension
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
Install dependencies in the extension directory:
|
||||
|
||||
```bash
|
||||
cd extensions/cocos-node-editor
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Enable Extension
|
||||
|
||||
1. Open Cocos Creator
|
||||
2. Go to **Extensions → Extension Manager**
|
||||
3. Find `cocos-node-editor` and enable it
|
||||
|
||||
## Opening the Blueprint Editor
|
||||
|
||||
Open the blueprint editor panel via menu **Panel → Node Editor**.
|
||||
|
||||
## Editor Interface
|
||||
|
||||
### Toolbar
|
||||
|
||||
| Button | Shortcut | Function |
|
||||
|--------|----------|----------|
|
||||
| New | - | Create empty blueprint |
|
||||
| Load | - | Load blueprint from file |
|
||||
| Save | `Ctrl+S` | Save blueprint to file |
|
||||
| Undo | `Ctrl+Z` | Undo last operation |
|
||||
| Redo | `Ctrl+Shift+Z` | Redo operation |
|
||||
| Cut | `Ctrl+X` | Cut selected nodes |
|
||||
| Copy | `Ctrl+C` | Copy selected nodes |
|
||||
| Paste | `Ctrl+V` | Paste nodes |
|
||||
| Delete | `Delete` | Delete selected items |
|
||||
| Rescan | - | Rescan project for blueprint nodes |
|
||||
|
||||
### Canvas Operations
|
||||
|
||||
- **Right-click on canvas**: Open node addition menu
|
||||
- **Drag nodes**: Move node position
|
||||
- **Click node**: Select node
|
||||
- **Ctrl+Click**: Multi-select nodes
|
||||
- **Drag pin to pin**: Create connection
|
||||
- **Scroll wheel**: Zoom canvas
|
||||
- **Middle-click drag**: Pan canvas
|
||||
|
||||
### Node Menu
|
||||
|
||||
Right-clicking on canvas shows the node menu:
|
||||
|
||||
- Search box at top for quick node search
|
||||
- Nodes grouped by category
|
||||
- Press `Enter` to quickly add first search result
|
||||
- Press `Esc` to close menu
|
||||
|
||||
## Blueprint File Format
|
||||
|
||||
Blueprints are saved as `.blueprint.json` files, fully compatible with runtime:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"type": "blueprint",
|
||||
"metadata": {
|
||||
"name": "My Blueprint",
|
||||
"createdAt": 1704307200000,
|
||||
"modifiedAt": 1704307200000
|
||||
},
|
||||
"variables": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"type": "PrintString",
|
||||
"position": { "x": 100, "y": 200 },
|
||||
"data": {}
|
||||
}
|
||||
],
|
||||
"connections": [
|
||||
{
|
||||
"id": "conn-1",
|
||||
"fromNodeId": "node-1",
|
||||
"fromPin": "exec",
|
||||
"toNodeId": "node-2",
|
||||
"toPin": "exec"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Running Blueprints in Game
|
||||
|
||||
Use ECS system to manage and execute blueprints.
|
||||
|
||||
### 1. Define Blueprint Component
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent, Property, Serialize } from '@esengine/ecs-framework';
|
||||
import type { BlueprintAsset } from '@esengine/blueprint';
|
||||
|
||||
@ECSComponent('Blueprint')
|
||||
export class BlueprintComponent extends Component {
|
||||
@Serialize()
|
||||
@Property({ type: 'asset', label: 'Blueprint Asset' })
|
||||
blueprintPath: string = '';
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Auto Start' })
|
||||
autoStart: boolean = true;
|
||||
|
||||
// Runtime data (not serialized)
|
||||
blueprintAsset: BlueprintAsset | null = null;
|
||||
vm: BlueprintVM | null = null;
|
||||
isStarted: boolean = false;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create Blueprint Execution System
|
||||
|
||||
```typescript
|
||||
import { EntitySystem, Matcher, Entity } from '@esengine/ecs-framework';
|
||||
import {
|
||||
BlueprintVM,
|
||||
validateBlueprintAsset
|
||||
} from '@esengine/blueprint';
|
||||
import { BlueprintComponent } from './BlueprintComponent';
|
||||
|
||||
export class BlueprintExecutionSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(BlueprintComponent));
|
||||
}
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
const dt = Time.deltaTime;
|
||||
|
||||
for (const entity of entities) {
|
||||
const bp = entity.getComponent(BlueprintComponent)!;
|
||||
|
||||
// Skip entities without blueprint asset
|
||||
if (!bp.blueprintAsset) continue;
|
||||
|
||||
// Initialize VM
|
||||
if (!bp.vm) {
|
||||
bp.vm = new BlueprintVM(bp.blueprintAsset, entity, this.scene!);
|
||||
}
|
||||
|
||||
// Auto start
|
||||
if (bp.autoStart && !bp.isStarted) {
|
||||
bp.vm.start();
|
||||
bp.isStarted = true;
|
||||
}
|
||||
|
||||
// Update blueprint
|
||||
if (bp.isStarted) {
|
||||
bp.vm.tick(dt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override onRemoved(entity: Entity): void {
|
||||
const bp = entity.getComponent(BlueprintComponent);
|
||||
if (bp?.vm && bp.isStarted) {
|
||||
bp.vm.stop();
|
||||
bp.vm = null;
|
||||
bp.isStarted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Load Blueprint and Add to Entity
|
||||
|
||||
```typescript
|
||||
import { resources, JsonAsset } from 'cc';
|
||||
import { validateBlueprintAsset } from '@esengine/blueprint';
|
||||
|
||||
// Load blueprint asset
|
||||
async function loadBlueprint(path: string): Promise<BlueprintAsset | null> {
|
||||
return new Promise((resolve) => {
|
||||
resources.load(path, JsonAsset, (err, asset) => {
|
||||
if (err || !asset) {
|
||||
console.error('Failed to load blueprint:', err);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = asset.json;
|
||||
if (validateBlueprintAsset(data)) {
|
||||
resolve(data as BlueprintAsset);
|
||||
} else {
|
||||
console.error('Invalid blueprint format');
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Create entity with blueprint
|
||||
async function createBlueprintEntity(scene: IScene, blueprintPath: string): Promise<Entity> {
|
||||
const entity = scene.createEntity('BlueprintEntity');
|
||||
|
||||
const bpComponent = entity.addComponent(BlueprintComponent);
|
||||
bpComponent.blueprintPath = blueprintPath;
|
||||
bpComponent.blueprintAsset = await loadBlueprint(blueprintPath);
|
||||
|
||||
return entity;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Register System to Scene
|
||||
|
||||
```typescript
|
||||
// During scene initialization
|
||||
scene.addSystem(new BlueprintExecutionSystem());
|
||||
```
|
||||
|
||||
## Creating Custom Nodes
|
||||
|
||||
### Using Decorators for Components
|
||||
|
||||
Use decorators to automatically generate blueprint nodes from components:
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent } from '@esengine/ecs-framework';
|
||||
import { BlueprintExpose, BlueprintProperty, BlueprintMethod } from '@esengine/blueprint';
|
||||
|
||||
@ECSComponent('Health')
|
||||
@BlueprintExpose({ displayName: 'Health Component' })
|
||||
export class HealthComponent extends Component {
|
||||
@BlueprintProperty({ displayName: 'Current Health', category: 'number' })
|
||||
current: number = 100;
|
||||
|
||||
@BlueprintProperty({ displayName: 'Max Health', category: 'number' })
|
||||
max: number = 100;
|
||||
|
||||
@BlueprintMethod({ displayName: 'Heal', isExec: true })
|
||||
heal(amount: number): void {
|
||||
this.current = Math.min(this.current + amount, this.max);
|
||||
}
|
||||
|
||||
@BlueprintMethod({ displayName: 'Take Damage', isExec: true })
|
||||
takeDamage(amount: number): void {
|
||||
this.current = Math.max(this.current - amount, 0);
|
||||
}
|
||||
|
||||
@BlueprintMethod({ displayName: 'Is Dead' })
|
||||
isDead(): boolean {
|
||||
return this.current <= 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Register Component Nodes
|
||||
|
||||
```typescript
|
||||
import { registerAllComponentNodes } from '@esengine/blueprint';
|
||||
|
||||
// Register all decorated components at application startup
|
||||
registerAllComponentNodes();
|
||||
```
|
||||
|
||||
### Manual Node Definition (Advanced)
|
||||
|
||||
For fully custom node logic:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
BlueprintNodeTemplate,
|
||||
INodeExecutor,
|
||||
RegisterNode,
|
||||
ExecutionContext,
|
||||
ExecutionResult
|
||||
} from '@esengine/blueprint';
|
||||
|
||||
const MyNodeTemplate: BlueprintNodeTemplate = {
|
||||
type: 'MyCustomNode',
|
||||
title: 'My Custom Node',
|
||||
category: 'custom',
|
||||
description: 'Custom node example',
|
||||
inputs: [
|
||||
{ name: 'exec', type: 'exec', direction: 'input', isExec: true },
|
||||
{ name: 'value', type: 'number', direction: 'input', defaultValue: 0 }
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'exec', type: 'exec', direction: 'output', isExec: true },
|
||||
{ name: 'result', type: 'number', direction: 'output' }
|
||||
]
|
||||
};
|
||||
|
||||
@RegisterNode(MyNodeTemplate)
|
||||
class MyNodeExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
const value = context.getInput<number>(node.id, 'value');
|
||||
return {
|
||||
outputs: { result: value * 2 },
|
||||
nextExec: 'exec'
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Node Categories
|
||||
|
||||
| Category | Description | Color |
|
||||
|----------|-------------|-------|
|
||||
| `event` | Event nodes | Red |
|
||||
| `flow` | Flow control | Gray |
|
||||
| `entity` | Entity operations | Blue |
|
||||
| `component` | Component access | Cyan |
|
||||
| `math` | Math operations | Green |
|
||||
| `logic` | Logic operations | Red |
|
||||
| `variable` | Variable access | Purple |
|
||||
| `time` | Time utilities | Cyan |
|
||||
| `debug` | Debug utilities | Gray |
|
||||
| `custom` | Custom nodes | Blue-gray |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **File Organization**
|
||||
- Place blueprint files in `assets/blueprints/` directory
|
||||
- Use meaningful file names like `player-controller.blueprint.json`
|
||||
|
||||
2. **Component Design**
|
||||
- Use `@BlueprintExpose` to mark components that should be exposed to blueprints
|
||||
- Provide clear `displayName` for properties and methods
|
||||
- Mark execution methods with `isExec: true`
|
||||
|
||||
3. **Performance Considerations**
|
||||
- Avoid heavy computation in Tick events
|
||||
- Use variables to cache intermediate results
|
||||
- Pure function nodes automatically cache outputs
|
||||
|
||||
4. **Debugging Tips**
|
||||
- Use Print nodes to output intermediate values
|
||||
- Enable `vm.debug = true` to view execution logs
|
||||
|
||||
## FAQ
|
||||
|
||||
### Q: Node menu is empty?
|
||||
|
||||
A: Click the **Rescan** button to scan for blueprint node classes in your project. Make sure you have called `registerAllComponentNodes()`.
|
||||
|
||||
### Q: Blueprint doesn't execute?
|
||||
|
||||
A: Check:
|
||||
1. Entity has `BlueprintComponent` added
|
||||
2. `BlueprintExecutionSystem` is registered to scene
|
||||
3. `blueprintAsset` is correctly loaded
|
||||
4. `autoStart` is `true`
|
||||
|
||||
### Q: How to trigger custom events?
|
||||
|
||||
A: Trigger through VM:
|
||||
```typescript
|
||||
const bp = entity.getComponent(BlueprintComponent);
|
||||
bp.vm?.triggerCustomEvent('OnPickup', { item: itemEntity });
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Blueprint Runtime API](/en/modules/blueprint/) - BlueprintVM and core API
|
||||
- [Custom Nodes](/en/modules/blueprint/custom-nodes) - Detailed node creation guide
|
||||
- [Built-in Nodes](/en/modules/blueprint/nodes) - Built-in node reference
|
||||
@@ -28,13 +28,13 @@ const MyNodeTemplate: BlueprintNodeTemplate = {
|
||||
## Implementing Node Executor
|
||||
|
||||
```typescript
|
||||
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
|
||||
import { INodeExecutor, RegisterNode, BlueprintNode, ExecutionContext, ExecutionResult } from '@esengine/blueprint';
|
||||
|
||||
@RegisterNode(MyNodeTemplate)
|
||||
class MyNodeExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
// Get input
|
||||
const value = context.getInput<number>(node.id, 'value');
|
||||
// Get input (using evaluateInput)
|
||||
const value = context.evaluateInput(node.id, 'value', 0) as number;
|
||||
|
||||
// Execute logic
|
||||
const result = value * 2;
|
||||
@@ -100,29 +100,58 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
|
||||
};
|
||||
```
|
||||
|
||||
## Example: Input Handler Node
|
||||
## Example: ECS Component Operation Node
|
||||
|
||||
```typescript
|
||||
const InputMoveTemplate: BlueprintNodeTemplate = {
|
||||
type: 'InputMove',
|
||||
title: 'Get Movement Input',
|
||||
category: 'input',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'direction', type: 'vector2', direction: 'output' }
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import { BlueprintNodeTemplate, BlueprintNode } from '@esengine/blueprint';
|
||||
import { ExecutionContext, ExecutionResult } from '@esengine/blueprint';
|
||||
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
|
||||
|
||||
// Custom heal node
|
||||
const HealEntityTemplate: BlueprintNodeTemplate = {
|
||||
type: 'HealEntity',
|
||||
title: 'Heal Entity',
|
||||
category: 'gameplay',
|
||||
color: '#22aa22',
|
||||
description: 'Heal an entity with HealthComponent',
|
||||
keywords: ['heal', 'health', 'restore'],
|
||||
menuPath: ['Gameplay', 'Combat', 'Heal Entity'],
|
||||
inputs: [
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'entity', type: 'entity', displayName: 'Target' },
|
||||
{ name: 'amount', type: 'float', displayName: 'Amount', defaultValue: 10 }
|
||||
],
|
||||
isPure: true
|
||||
outputs: [
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'newHealth', type: 'float', displayName: 'New Health' }
|
||||
]
|
||||
};
|
||||
|
||||
@RegisterNode(InputMoveTemplate)
|
||||
class InputMoveExecutor implements INodeExecutor {
|
||||
@RegisterNode(HealEntityTemplate)
|
||||
class HealEntityExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
const input = context.scene.services.get(InputServiceToken);
|
||||
const direction = {
|
||||
x: input.getAxis('horizontal'),
|
||||
y: input.getAxis('vertical')
|
||||
};
|
||||
return { outputs: { direction } };
|
||||
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
|
||||
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
|
||||
|
||||
if (!entity || entity.isDestroyed) {
|
||||
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
|
||||
}
|
||||
|
||||
// Get HealthComponent
|
||||
const health = entity.components.find(c =>
|
||||
(c.constructor as any).__componentName__ === 'Health'
|
||||
) as any;
|
||||
|
||||
if (health) {
|
||||
health.current = Math.min(health.current + amount, health.max);
|
||||
return {
|
||||
outputs: { newHealth: health.current },
|
||||
nextExec: 'exec'
|
||||
};
|
||||
}
|
||||
|
||||
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
610
docs/src/content/docs/en/modules/blueprint/editor-guide.md
Normal file
610
docs/src/content/docs/en/modules/blueprint/editor-guide.md
Normal file
@@ -0,0 +1,610 @@
|
||||
---
|
||||
title: "Blueprint Editor User Guide"
|
||||
description: "Complete guide for using the Cocos Creator Blueprint Visual Scripting Editor"
|
||||
---
|
||||
|
||||
<script src="/js/blueprint-graph.js"></script>
|
||||
|
||||
This guide covers how to use the Blueprint Visual Scripting Editor in Cocos Creator.
|
||||
|
||||
## Download & Installation
|
||||
|
||||
### Download
|
||||
|
||||
Download the latest version from GitHub Release (Free):
|
||||
|
||||
**[Download Cocos Node Editor v1.1.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.1.0)**
|
||||
|
||||
> QQ Group: **481923584** | Website: [esengine.cn](https://esengine.cn/)
|
||||
|
||||
### Installation Steps
|
||||
|
||||
1. Extract `cocos-node-editor.zip` to your project's `extensions` directory:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── assets/
|
||||
├── extensions/
|
||||
│ └── cocos-node-editor/ ← Extract here
|
||||
└── ...
|
||||
```
|
||||
|
||||
2. Restart Cocos Creator
|
||||
|
||||
3. Confirm the plugin is enabled via **Extensions → Extension Manager**
|
||||
|
||||
4. Open the editor via **Panel → Node Editor**
|
||||
|
||||
## Interface Overview
|
||||
|
||||
- **Toolbar** - Located at the top, contains New, Open, Save, Undo, Redo operations
|
||||
- **Variables Panel** - Located at the top-left, for defining and managing blueprint variables
|
||||
- **Canvas Area** - Main area for placing and connecting nodes
|
||||
- **Node Menu** - Right-click on empty canvas to open, lists all available nodes by category
|
||||
|
||||
## Canvas Operations
|
||||
|
||||
| Operation | Method |
|
||||
|-----------|--------|
|
||||
| Pan canvas | Middle-click drag / Alt + Left-click drag |
|
||||
| Zoom canvas | Mouse wheel |
|
||||
| Open node menu | Right-click on empty space |
|
||||
| Box select nodes | Drag on empty canvas |
|
||||
| Additive select | Ctrl + Drag |
|
||||
| Delete selected | Delete key |
|
||||
|
||||
## Node Operations
|
||||
|
||||
### Adding Nodes
|
||||
|
||||
1. **Drag from Node Panel** - Drag nodes from the left panel onto the canvas
|
||||
2. **Right-click Menu** - Right-click on empty canvas space to select nodes
|
||||
|
||||
### Connecting Nodes
|
||||
|
||||
1. Drag from an output pin to an input pin
|
||||
2. Compatible pins will highlight
|
||||
3. Release to complete the connection
|
||||
|
||||
**Pin Type Reference:**
|
||||
|
||||
| Pin Color | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| White ▶ | Exec | Execution flow (controls order) |
|
||||
| Cyan ◆ | Entity | Entity reference |
|
||||
| Purple ◆ | Component | Component reference |
|
||||
| Light Blue ◆ | String | String value |
|
||||
| Green ◆ | Number | Numeric value |
|
||||
| Red ◆ | Boolean | Boolean value |
|
||||
| Gray ◆ | Any | Any type |
|
||||
|
||||
### Deleting Connections
|
||||
|
||||
Click a connection line to select it, then press Delete.
|
||||
|
||||
## Node Types Reference
|
||||
|
||||
### Event Nodes
|
||||
|
||||
Event nodes are entry points for blueprint execution, triggered when specific events occur.
|
||||
|
||||
| Node | Trigger | Outputs |
|
||||
|------|---------|---------|
|
||||
| **Event BeginPlay** | When blueprint starts | Exec, Self (Entity) |
|
||||
| **Event Tick** | Every frame | Exec, Delta Time |
|
||||
| **Event EndPlay** | When blueprint stops | Exec |
|
||||
|
||||
**Example: Print message on game start**
|
||||
```
|
||||
[Event BeginPlay] ──Exec──→ [Print]
|
||||
└─ Message: "Game Started!"
|
||||
```
|
||||
|
||||
### Entity Nodes
|
||||
|
||||
Nodes for operating on ECS entities.
|
||||
|
||||
| Node | Function | Inputs | Outputs |
|
||||
|------|----------|--------|---------|
|
||||
| **Get Self** | Get current entity | - | Entity |
|
||||
| **Create Entity** | Create new entity | Exec, Name | Exec, Entity |
|
||||
| **Destroy Entity** | Destroy entity | Exec, Entity | Exec |
|
||||
| **Find Entity By Name** | Find by name | Name | Entity |
|
||||
| **Find Entities By Tag** | Find by tag | Tag | Entity[] |
|
||||
| **Is Valid** | Check entity validity | Entity | Boolean |
|
||||
| **Get/Set Entity Name** | Get/Set name | Entity | String |
|
||||
| **Set Active** | Set active state | Exec, Entity, Active | Exec |
|
||||
|
||||
**Example: Create new entity**
|
||||
```
|
||||
[Event BeginPlay] ──→ [Create Entity] ──→ [Add Component]
|
||||
└─ Name: "Bullet" └─ Type: Transform
|
||||
```
|
||||
|
||||
### Component Nodes
|
||||
|
||||
Access and manipulate ECS components.
|
||||
|
||||
| Node | Function |
|
||||
|------|----------|
|
||||
| **Has Component** | Check if entity has component |
|
||||
| **Get Component** | Get component instance |
|
||||
| **Add Component** | Add component to entity |
|
||||
| **Remove Component** | Remove component |
|
||||
| **Get/Set Property** | Get/Set component property |
|
||||
|
||||
**Example: Modify Transform component**
|
||||
```
|
||||
[Get Self] ─Entity─→ [Get Component: Transform] ─Component─→ [Set Property]
|
||||
├─ Property: x
|
||||
└─ Value: 100
|
||||
```
|
||||
|
||||
### Flow Control Nodes
|
||||
|
||||
Nodes that control execution flow.
|
||||
|
||||
#### Branch
|
||||
|
||||
Conditional branching, similar to if/else.
|
||||
|
||||
```
|
||||
┌─ True ──→ [DoSomething]
|
||||
[Branch]─┤
|
||||
└─ False ─→ [DoOtherThing]
|
||||
```
|
||||
|
||||
#### Sequence
|
||||
|
||||
Execute multiple branches in order.
|
||||
|
||||
```
|
||||
┌─ Then 0 ──→ [Step1]
|
||||
[Sequence]─┼─ Then 1 ──→ [Step2]
|
||||
└─ Then 2 ──→ [Step3]
|
||||
```
|
||||
|
||||
#### For Loop
|
||||
|
||||
Execute a specified number of times.
|
||||
|
||||
```
|
||||
[For Loop] ─Loop Body─→ [Execute each iteration]
|
||||
│
|
||||
└─ Completed ────→ [Execute after loop ends]
|
||||
```
|
||||
|
||||
| Input | Description |
|
||||
|-------|-------------|
|
||||
| First Index | Starting index |
|
||||
| Last Index | Ending index |
|
||||
|
||||
| Output | Description |
|
||||
|--------|-------------|
|
||||
| Loop Body | Execute each iteration |
|
||||
| Index | Current index |
|
||||
| Completed | Execute after loop ends |
|
||||
|
||||
#### For Each
|
||||
|
||||
Iterate over array elements.
|
||||
|
||||
#### While Loop
|
||||
|
||||
Loop while condition is true.
|
||||
|
||||
#### Do Once
|
||||
|
||||
Execute only once, skip afterwards.
|
||||
|
||||
#### Flip Flop
|
||||
|
||||
Alternate between A and B outputs each execution.
|
||||
|
||||
#### Gate
|
||||
|
||||
Control whether execution passes through via Open/Close/Toggle.
|
||||
|
||||
### Time Nodes
|
||||
|
||||
| Node | Function | Output Type |
|
||||
|------|----------|-------------|
|
||||
| **Delay** | Delay execution by specified time | Exec |
|
||||
| **Get Delta Time** | Get frame delta time | Number |
|
||||
| **Get Time** | Get total runtime | Number |
|
||||
|
||||
**Example: Execute after 2 second delay**
|
||||
```
|
||||
[Event BeginPlay] ──→ [Delay] ──→ [Print]
|
||||
└─ Duration: 2.0 └─ "Executed after 2s"
|
||||
```
|
||||
|
||||
### Math Nodes
|
||||
|
||||
| Node | Function |
|
||||
|------|----------|
|
||||
| **Add / Subtract / Multiply / Divide** | Arithmetic operations |
|
||||
| **Abs** | Absolute value |
|
||||
| **Clamp** | Clamp to range |
|
||||
| **Lerp** | Linear interpolation |
|
||||
| **Min / Max** | Minimum/Maximum value |
|
||||
| **Random Range** | Random number |
|
||||
| **Sin / Cos / Tan** | Trigonometric functions |
|
||||
|
||||
### Debug Nodes
|
||||
|
||||
| Node | Function |
|
||||
|------|----------|
|
||||
| **Print** | Output to console |
|
||||
|
||||
## Variable System
|
||||
|
||||
Variables store and share data within a blueprint.
|
||||
|
||||
### Creating Variables
|
||||
|
||||
1. Click the **+** button in the Variables panel
|
||||
2. Enter variable name
|
||||
3. Select variable type
|
||||
4. Set default value (optional)
|
||||
|
||||
### Using Variables
|
||||
|
||||
- **Drag to canvas** - Creates Get or Set node
|
||||
- **Get Node** - Read variable value
|
||||
- **Set Node** - Write variable value
|
||||
|
||||
### Variable Types
|
||||
|
||||
| Type | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| Boolean | Boolean value | false |
|
||||
| Number | Numeric value | 0 |
|
||||
| String | String value | "" |
|
||||
| Entity | Entity reference | null |
|
||||
| Vector2 | 2D vector | (0, 0) |
|
||||
| Vector3 | 3D vector | (0, 0, 0) |
|
||||
|
||||
### Variable Node Error State
|
||||
|
||||
If you delete a variable but nodes still reference it:
|
||||
- Nodes display a **red border** and **warning icon**
|
||||
- You need to recreate the variable or delete these nodes
|
||||
|
||||
## Node Grouping
|
||||
|
||||
You can organize multiple nodes into a visual group box to help manage complex blueprints.
|
||||
|
||||
### Creating a Group
|
||||
|
||||
1. Box-select or Ctrl+click to select multiple nodes (at least 2)
|
||||
2. Right-click on the selected nodes
|
||||
3. Choose **Create Group**
|
||||
4. A group box will automatically wrap all selected nodes
|
||||
|
||||
### Group Operations
|
||||
|
||||
| Action | Method |
|
||||
|--------|--------|
|
||||
| Move group | Drag the group header, all nodes move together |
|
||||
| Ungroup | Right-click on group box → **Ungroup** |
|
||||
|
||||
### Features
|
||||
|
||||
- **Dynamic sizing**: Group box automatically resizes to wrap all nodes
|
||||
- **Independent movement**: You can move nodes within the group individually, and the box adjusts
|
||||
- **Editor only**: Groups are purely visual organization, no runtime impact
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Function |
|
||||
|----------|----------|
|
||||
| `Ctrl + S` | Save blueprint |
|
||||
| `Ctrl + Z` | Undo |
|
||||
| `Ctrl + Shift + Z` | Redo |
|
||||
| `Ctrl + C` | Copy selected nodes |
|
||||
| `Ctrl + X` | Cut selected nodes |
|
||||
| `Ctrl + V` | Paste nodes |
|
||||
| `Delete` | Delete selected items |
|
||||
| `Ctrl + A` | Select all |
|
||||
|
||||
## Save & Load
|
||||
|
||||
### Saving Blueprints
|
||||
|
||||
1. Click the **Save** button in the toolbar
|
||||
2. Choose save location (**must be saved in `assets/resources` directory**, otherwise Cocos Creator cannot load dynamically)
|
||||
3. File extension is `.blueprint.json`
|
||||
|
||||
> **Important**: Blueprint files must be placed in the `resources` directory for runtime loading via `cc.resources.load()`.
|
||||
|
||||
### Loading Blueprints
|
||||
|
||||
1. Click the **Open** button in the toolbar
|
||||
2. Select a `.blueprint.json` file
|
||||
|
||||
### Blueprint File Format
|
||||
|
||||
Blueprints are saved as JSON, compatible with `@esengine/blueprint` runtime:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"type": "blueprint",
|
||||
"metadata": {
|
||||
"name": "PlayerController",
|
||||
"description": "Player control logic"
|
||||
},
|
||||
"variables": [],
|
||||
"nodes": [],
|
||||
"connections": []
|
||||
}
|
||||
```
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Example 1: Movement Control
|
||||
|
||||
Move entity every frame:
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"ex1-exec","to":"ex1-setprop","type":"exec"},{"from":"ex1-delta","to":"ex1-mul-a","type":"float"},{"from":"ex1-mul-result","to":"ex1-x","type":"float"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event Tick</span>
|
||||
<span class="bp-header-exec" data-pin="ex1-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="ex1-delta"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Delta Time</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 200px; top: 110px; width: 120px;">
|
||||
<div class="bp-node-header math">Multiply</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex1-mul-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">B (Speed)</span>
|
||||
<span class="bp-pin-value">100</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="ex1-mul-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 380px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Set Property</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex1-setprop"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7030c0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Target</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex1-x"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Example 2: Health System
|
||||
|
||||
Check death after taking damage. `Event OnDamage` is a custom event node that can be triggered from code via `vm.triggerCustomEvent('OnDamage', { damage: 50 })`:
|
||||
|
||||
<div class="bp-graph" data-graph='{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "event", "title": "Event OnDamage", "category": "event",
|
||||
"outputs": [
|
||||
{"id": "event-exec", "type": "exec", "inHeader": true},
|
||||
{"id": "event-self", "type": "entity", "label": "Self"},
|
||||
{"id": "event-damage", "type": "float", "label": "Damage"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "getcomp", "title": "Get Component", "category": "function",
|
||||
"inputs": [
|
||||
{"id": "getcomp-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "getcomp-entity", "type": "entity", "label": "Entity"},
|
||||
{"id": "getcomp-type", "type": "string", "label": "Type", "value": "Health", "connected": false}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "getcomp-out", "type": "exec"},
|
||||
{"id": "getcomp-comp", "type": "component", "label": "Component"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "getprop", "title": "Get Property", "category": "pure",
|
||||
"inputs": [
|
||||
{"id": "getprop-target", "type": "component", "label": "Target"},
|
||||
{"id": "getprop-prop", "type": "string", "label": "Property", "value": "current", "connected": false}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "getprop-val", "type": "float", "label": "Value"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sub", "title": "Subtract", "category": "math",
|
||||
"inputs": [
|
||||
{"id": "sub-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "sub-a", "type": "float", "label": "A"},
|
||||
{"id": "sub-b", "type": "float", "label": "B"}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "sub-out", "type": "exec"},
|
||||
{"id": "sub-result", "type": "float", "label": "Result"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "setprop", "title": "Set Property", "category": "function",
|
||||
"inputs": [
|
||||
{"id": "setprop-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "setprop-target", "type": "component", "label": "Target"},
|
||||
{"id": "setprop-prop", "type": "string", "label": "Property", "value": "current", "connected": false},
|
||||
{"id": "setprop-val", "type": "float", "label": "Value"}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "setprop-out", "type": "exec"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lte", "title": "Less Or Equal", "category": "pure",
|
||||
"inputs": [
|
||||
{"id": "lte-a", "type": "float", "label": "A"},
|
||||
{"id": "lte-b", "type": "float", "label": "B", "value": "0", "connected": false}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "lte-result", "type": "bool", "label": "Result"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "branch", "title": "Branch", "category": "flow",
|
||||
"inputs": [
|
||||
{"id": "branch-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "branch-cond", "type": "bool", "label": "Condition"}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "branch-true", "type": "exec", "label": "True"},
|
||||
{"id": "branch-false", "type": "exec", "label": "False"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "destroy", "title": "Destroy Entity", "category": "function",
|
||||
"inputs": [
|
||||
{"id": "destroy-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "destroy-entity", "type": "entity", "label": "Entity"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": [
|
||||
{"from": "event-exec", "to": "getcomp-exec", "type": "exec"},
|
||||
{"from": "getcomp-out", "to": "sub-exec", "type": "exec"},
|
||||
{"from": "sub-out", "to": "setprop-exec", "type": "exec"},
|
||||
{"from": "setprop-out", "to": "branch-exec", "type": "exec"},
|
||||
{"from": "branch-true", "to": "destroy-exec", "type": "exec"},
|
||||
{"from": "event-self", "to": "getcomp-entity", "type": "entity"},
|
||||
{"from": "event-self", "to": "destroy-entity", "type": "entity"},
|
||||
{"from": "getcomp-comp", "to": "getprop-target", "type": "component"},
|
||||
{"from": "getcomp-comp", "to": "setprop-target", "type": "component"},
|
||||
{"from": "getprop-val", "to": "sub-a", "type": "float"},
|
||||
{"from": "event-damage", "to": "sub-b", "type": "float"},
|
||||
{"from": "sub-result", "to": "setprop-val", "type": "float"},
|
||||
{"from": "sub-result", "to": "lte-a", "type": "float"},
|
||||
{"from": "lte-result", "to": "branch-cond", "type": "bool"}
|
||||
]
|
||||
}'></div>
|
||||
|
||||
### Example 3: Delayed Spawning
|
||||
|
||||
Spawn an enemy every 2 seconds:
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"ex3-begin-exec","to":"ex3-loop","type":"exec"},{"from":"ex3-loop-body","to":"ex3-delay","type":"exec"},{"from":"ex3-delay-done","to":"ex3-create","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="ex3-begin-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 240px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header flow">Do N Times</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex3-loop"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">N</span>
|
||||
<span class="bp-pin-value">10</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="ex3-loop-body"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Loop Body</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">Index</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 430px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header time">Delay</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex3-delay"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Duration</span>
|
||||
<span class="bp-pin-value">2.0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="ex3-delay-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 610px; top: 20px; width: 140px;">
|
||||
<div class="bp-node-header function">Create Entity</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex3-create"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Name</span>
|
||||
<span class="bp-pin-value">"Enemy"</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Q: Nodes won't connect?
|
||||
|
||||
Check if pin types are compatible. Execution pins (white) can only connect to execution pins. Data pins need matching types.
|
||||
|
||||
### Q: Blueprint not executing?
|
||||
|
||||
1. Ensure entity has `BlueprintComponent` attached
|
||||
2. Ensure scene has `BlueprintSystem` added
|
||||
3. Check if `autoStart` is `true`
|
||||
|
||||
### Q: How to debug?
|
||||
|
||||
Use **Print** nodes to output variable values to the console.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ECS Node Reference](/en/modules/blueprint/nodes) - Complete node list
|
||||
- [Custom Nodes](/en/modules/blueprint/custom-nodes) - Create custom nodes
|
||||
- [Runtime Integration](/en/modules/blueprint/vm) - Blueprint VM API
|
||||
- [Examples](/en/modules/blueprint/examples) - More game logic examples
|
||||
@@ -3,85 +3,127 @@ title: "Examples"
|
||||
description: "ECS integration and best practices"
|
||||
---
|
||||
|
||||
## Player Control Blueprint
|
||||
## Complete Game Integration Example
|
||||
|
||||
```typescript
|
||||
// Define input handling node
|
||||
const InputMoveTemplate: BlueprintNodeTemplate = {
|
||||
type: 'InputMove',
|
||||
title: 'Get Movement Input',
|
||||
category: 'input',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'direction', type: 'vector2', direction: 'output' }
|
||||
],
|
||||
isPure: true
|
||||
};
|
||||
import { Scene, Core, Component, ECSComponent } from '@esengine/ecs-framework';
|
||||
import {
|
||||
BlueprintSystem,
|
||||
BlueprintComponent,
|
||||
BlueprintExpose,
|
||||
BlueprintProperty,
|
||||
BlueprintMethod
|
||||
} from '@esengine/blueprint';
|
||||
|
||||
@RegisterNode(InputMoveTemplate)
|
||||
class InputMoveExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
const input = context.scene.services.get(InputServiceToken);
|
||||
const direction = {
|
||||
x: input.getAxis('horizontal'),
|
||||
y: input.getAxis('vertical')
|
||||
};
|
||||
return { outputs: { direction } };
|
||||
// 1. Define game components
|
||||
@ECSComponent('Player')
|
||||
@BlueprintExpose({ displayName: 'Player', category: 'gameplay' })
|
||||
export class PlayerComponent extends Component {
|
||||
@BlueprintProperty({ displayName: 'Move Speed', type: 'float' })
|
||||
moveSpeed: number = 5;
|
||||
|
||||
@BlueprintProperty({ displayName: 'Score', type: 'int' })
|
||||
score: number = 0;
|
||||
|
||||
@BlueprintMethod({ displayName: 'Add Score' })
|
||||
addScore(points: number): void {
|
||||
this.score += points;
|
||||
}
|
||||
}
|
||||
|
||||
@ECSComponent('Health')
|
||||
@BlueprintExpose({ displayName: 'Health', category: 'gameplay' })
|
||||
export class HealthComponent extends Component {
|
||||
@BlueprintProperty({ displayName: 'Current Health' })
|
||||
current: number = 100;
|
||||
|
||||
@BlueprintProperty({ displayName: 'Max Health' })
|
||||
max: number = 100;
|
||||
|
||||
@BlueprintMethod({ displayName: 'Heal' })
|
||||
heal(amount: number): void {
|
||||
this.current = Math.min(this.current + amount, this.max);
|
||||
}
|
||||
|
||||
@BlueprintMethod({ displayName: 'Take Damage' })
|
||||
takeDamage(amount: number): boolean {
|
||||
this.current -= amount;
|
||||
return this.current <= 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Initialize game
|
||||
async function initGame() {
|
||||
const scene = new Scene();
|
||||
|
||||
// Add blueprint system
|
||||
scene.addSystem(new BlueprintSystem());
|
||||
|
||||
Core.setScene(scene);
|
||||
|
||||
// 3. Create player
|
||||
const player = scene.createEntity('Player');
|
||||
player.addComponent(new PlayerComponent());
|
||||
player.addComponent(new HealthComponent());
|
||||
|
||||
// Add blueprint control
|
||||
const blueprint = new BlueprintComponent();
|
||||
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
|
||||
player.addComponent(blueprint);
|
||||
}
|
||||
```
|
||||
|
||||
## State Switching Logic
|
||||
## Custom Node Example
|
||||
|
||||
```typescript
|
||||
// Implement state machine logic in blueprint
|
||||
const stateBlueprint = createEmptyBlueprint('PlayerState');
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import {
|
||||
BlueprintNodeTemplate,
|
||||
BlueprintNode,
|
||||
ExecutionContext,
|
||||
ExecutionResult,
|
||||
INodeExecutor,
|
||||
RegisterNode
|
||||
} from '@esengine/blueprint';
|
||||
|
||||
// Add state variable
|
||||
stateBlueprint.variables.push({
|
||||
name: 'currentState',
|
||||
type: 'string',
|
||||
defaultValue: 'idle',
|
||||
scope: 'instance'
|
||||
});
|
||||
|
||||
// Check state transitions in Tick event
|
||||
// ... implemented via node connections
|
||||
```
|
||||
|
||||
## Damage Handling System
|
||||
|
||||
```typescript
|
||||
// Custom damage node
|
||||
const ApplyDamageTemplate: BlueprintNodeTemplate = {
|
||||
type: 'ApplyDamage',
|
||||
title: 'Apply Damage',
|
||||
category: 'combat',
|
||||
color: '#aa2222',
|
||||
description: 'Apply damage to entity with Health component',
|
||||
keywords: ['damage', 'hurt', 'attack'],
|
||||
menuPath: ['Combat', 'Apply Damage'],
|
||||
inputs: [
|
||||
{ name: 'exec', type: 'exec', direction: 'input', isExec: true },
|
||||
{ name: 'target', type: 'entity', direction: 'input' },
|
||||
{ name: 'amount', type: 'number', direction: 'input', defaultValue: 10 }
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'target', type: 'entity', displayName: 'Target' },
|
||||
{ name: 'amount', type: 'float', displayName: 'Damage', defaultValue: 10 }
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'exec', type: 'exec', direction: 'output', isExec: true },
|
||||
{ name: 'killed', type: 'boolean', direction: 'output' }
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'killed', type: 'bool', displayName: 'Killed' }
|
||||
]
|
||||
};
|
||||
|
||||
@RegisterNode(ApplyDamageTemplate)
|
||||
class ApplyDamageExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
const target = context.getInput<Entity>(node.id, 'target');
|
||||
const amount = context.getInput<number>(node.id, 'amount');
|
||||
const target = context.evaluateInput(node.id, 'target', context.entity) as Entity;
|
||||
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
|
||||
|
||||
if (!target || target.isDestroyed) {
|
||||
return { outputs: { killed: false }, nextExec: 'exec' };
|
||||
}
|
||||
|
||||
const health = target.components.find(c =>
|
||||
(c.constructor as any).__componentName__ === 'Health'
|
||||
) as any;
|
||||
|
||||
const health = target.getComponent(HealthComponent);
|
||||
if (health) {
|
||||
health.current -= amount;
|
||||
const killed = health.current <= 0;
|
||||
return {
|
||||
outputs: { killed },
|
||||
nextExec: 'exec'
|
||||
};
|
||||
return { outputs: { killed }, nextExec: 'exec' };
|
||||
}
|
||||
|
||||
return { outputs: { killed: false }, nextExec: 'exec' };
|
||||
@@ -132,7 +174,8 @@ vm.maxStepsPerFrame = 1000;
|
||||
|
||||
```typescript
|
||||
// Enable debug mode for execution logs
|
||||
vm.debug = true;
|
||||
const blueprint = entity.getComponent(BlueprintComponent);
|
||||
blueprint.debug = true;
|
||||
|
||||
// Use Print nodes for intermediate values
|
||||
// Set breakpoints in editor
|
||||
|
||||
@@ -1,414 +1,162 @@
|
||||
---
|
||||
title: "Blueprint Visual Scripting"
|
||||
description: "Visual scripting system deeply integrated with ECS framework"
|
||||
---
|
||||
|
||||
`@esengine/blueprint` provides a full-featured visual scripting system supporting node-based programming, event-driven execution, and blueprint composition.
|
||||
`@esengine/blueprint` provides a visual scripting system deeply integrated with the ECS framework, supporting node-based programming to control entity behavior.
|
||||
|
||||
## Installation
|
||||
## Editor Download
|
||||
|
||||
Blueprint Editor Plugin for Cocos Creator (Free):
|
||||
|
||||
**[Download Cocos Node Editor v1.1.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.1.0)**
|
||||
|
||||
> QQ Group: **481923584** | Website: [esengine.cn](https://esengine.cn/)
|
||||
|
||||
For detailed usage instructions, see [Editor User Guide](./editor-guide).
|
||||
|
||||
## Runtime Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/blueprint
|
||||
```
|
||||
|
||||
## Core Features
|
||||
|
||||
- **Deep ECS Integration** - Built-in Entity and Component operation nodes
|
||||
- **Auto-generated Component Nodes** - Use decorators to mark components, auto-generate Get/Set/Call nodes
|
||||
- **Runtime Blueprint Execution** - Efficient virtual machine executes blueprint logic
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Add Blueprint System
|
||||
|
||||
```typescript
|
||||
import { Scene, Core } from '@esengine/ecs-framework';
|
||||
import { BlueprintSystem } from '@esengine/blueprint';
|
||||
|
||||
// Create scene and add blueprint system
|
||||
const scene = new Scene();
|
||||
scene.addSystem(new BlueprintSystem());
|
||||
|
||||
// Set scene
|
||||
Core.setScene(scene);
|
||||
```
|
||||
|
||||
### 2. Add Blueprint to Entity
|
||||
|
||||
```typescript
|
||||
import { BlueprintComponent } from '@esengine/blueprint';
|
||||
|
||||
// Create entity
|
||||
const player = scene.createEntity('Player');
|
||||
|
||||
// Add blueprint component
|
||||
const blueprint = new BlueprintComponent();
|
||||
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
|
||||
blueprint.autoStart = true;
|
||||
player.addComponent(blueprint);
|
||||
```
|
||||
|
||||
### 3. Mark Components (Auto-generate Blueprint Nodes)
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createBlueprintSystem,
|
||||
createBlueprintComponentData,
|
||||
NodeRegistry,
|
||||
RegisterNode
|
||||
BlueprintExpose,
|
||||
BlueprintProperty,
|
||||
BlueprintMethod
|
||||
} from '@esengine/blueprint';
|
||||
import { Component, ECSComponent } from '@esengine/ecs-framework';
|
||||
|
||||
// Create blueprint system
|
||||
const blueprintSystem = createBlueprintSystem(scene);
|
||||
@ECSComponent('Health')
|
||||
@BlueprintExpose({ displayName: 'Health', category: 'gameplay' })
|
||||
export class HealthComponent extends Component {
|
||||
@BlueprintProperty({ displayName: 'Current Health', type: 'float' })
|
||||
current: number = 100;
|
||||
|
||||
// Load blueprint asset
|
||||
const blueprint = await loadBlueprintAsset('player.bp');
|
||||
@BlueprintProperty({ displayName: 'Max Health', type: 'float' })
|
||||
max: number = 100;
|
||||
|
||||
// Create blueprint component data
|
||||
const componentData = createBlueprintComponentData();
|
||||
componentData.blueprintAsset = blueprint;
|
||||
@BlueprintMethod({
|
||||
displayName: 'Heal',
|
||||
params: [{ name: 'amount', type: 'float' }]
|
||||
})
|
||||
heal(amount: number): void {
|
||||
this.current = Math.min(this.current + amount, this.max);
|
||||
}
|
||||
|
||||
// Update in game loop
|
||||
function gameLoop(dt: number) {
|
||||
blueprintSystem.process(entities, dt);
|
||||
@BlueprintMethod({ displayName: 'Take Damage' })
|
||||
takeDamage(amount: number): boolean {
|
||||
this.current -= amount;
|
||||
return this.current <= 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
After marking, the following nodes will appear in the blueprint editor:
|
||||
- **Get Health** - Get Health component
|
||||
- **Get Current Health** - Get current property
|
||||
- **Set Current Health** - Set current property
|
||||
- **Heal** - Call heal method
|
||||
- **Take Damage** - Call takeDamage method
|
||||
|
||||
### Blueprint Asset Structure
|
||||
## ECS Integration Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Core.update() │
|
||||
│ ↓ │
|
||||
│ Scene.updateSystems() │
|
||||
│ ↓ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ BlueprintSystem │ │
|
||||
│ │ │ │
|
||||
│ │ Matcher.all(BlueprintComponent) │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ process(entities) → blueprint.tick() for each entity │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ BlueprintVM.tick(dt) │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ Execute Event/ECS/Flow Nodes │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Node Types
|
||||
|
||||
| Category | Description | Color |
|
||||
|----------|-------------|-------|
|
||||
| `event` | Event nodes (BeginPlay, Tick, EndPlay) | Red |
|
||||
| `entity` | ECS entity operations | Blue |
|
||||
| `component` | ECS component access | Cyan |
|
||||
| `flow` | Flow control (Branch, Sequence, Loop) | Gray |
|
||||
| `math` | Math operations | Green |
|
||||
| `time` | Time utilities (Delay, GetDeltaTime) | Cyan |
|
||||
| `debug` | Debug utilities (Print) | Gray |
|
||||
|
||||
## Blueprint Asset Structure
|
||||
|
||||
Blueprints are saved as `.bp` files:
|
||||
|
||||
```typescript
|
||||
interface BlueprintAsset {
|
||||
version: number; // Format version
|
||||
type: 'blueprint'; // Asset type
|
||||
metadata: BlueprintMetadata; // Metadata
|
||||
variables: BlueprintVariable[]; // Variable definitions
|
||||
nodes: BlueprintNode[]; // Node instances
|
||||
connections: BlueprintConnection[]; // Connections
|
||||
version: number;
|
||||
type: 'blueprint';
|
||||
metadata: {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
variables: BlueprintVariable[];
|
||||
nodes: BlueprintNode[];
|
||||
connections: BlueprintConnection[];
|
||||
}
|
||||
```
|
||||
|
||||
### Node Categories
|
||||
|
||||
| Category | Description | Color |
|
||||
|----------|-------------|-------|
|
||||
| `event` | Event nodes (entry points) | Red |
|
||||
| `flow` | Flow control | Gray |
|
||||
| `entity` | Entity operations | Blue |
|
||||
| `component` | Component access | Cyan |
|
||||
| `math` | Math operations | Green |
|
||||
| `logic` | Logic operations | Red |
|
||||
| `variable` | Variable access | Purple |
|
||||
| `time` | Time utilities | Cyan |
|
||||
| `debug` | Debug utilities | Gray |
|
||||
|
||||
### Pin Types
|
||||
|
||||
Nodes connect through pins:
|
||||
|
||||
```typescript
|
||||
interface BlueprintPinDefinition {
|
||||
name: string; // Pin name
|
||||
type: PinDataType; // Data type
|
||||
direction: 'input' | 'output';
|
||||
isExec?: boolean; // Execution pin
|
||||
defaultValue?: unknown;
|
||||
}
|
||||
|
||||
type PinDataType =
|
||||
| 'exec' // Execution flow
|
||||
| 'boolean' // Boolean
|
||||
| 'number' // Number
|
||||
| 'string' // String
|
||||
| 'vector2' // 2D vector
|
||||
| 'vector3' // 3D vector
|
||||
| 'entity' // Entity reference
|
||||
| 'component' // Component reference
|
||||
| 'any'; // Any type
|
||||
```
|
||||
|
||||
### Variable Scopes
|
||||
|
||||
```typescript
|
||||
type VariableScope =
|
||||
| 'local' // Per execution
|
||||
| 'instance' // Per entity
|
||||
| 'global'; // Shared globally
|
||||
```
|
||||
|
||||
## Virtual Machine API
|
||||
|
||||
### BlueprintVM
|
||||
|
||||
The virtual machine executes blueprint graphs:
|
||||
|
||||
```typescript
|
||||
import { BlueprintVM } from '@esengine/blueprint';
|
||||
|
||||
const vm = new BlueprintVM(blueprintAsset, entity, scene);
|
||||
|
||||
vm.start(); // Start (triggers BeginPlay)
|
||||
vm.tick(deltaTime); // Update (triggers Tick)
|
||||
vm.stop(); // Stop (triggers EndPlay)
|
||||
|
||||
vm.pause();
|
||||
vm.resume();
|
||||
|
||||
// Trigger events
|
||||
vm.triggerEvent('EventCollision', { other: otherEntity });
|
||||
vm.triggerCustomEvent('OnDamage', { amount: 50 });
|
||||
|
||||
// Debug mode
|
||||
vm.debug = true;
|
||||
```
|
||||
|
||||
### Execution Context
|
||||
|
||||
```typescript
|
||||
interface ExecutionContext {
|
||||
blueprint: BlueprintAsset;
|
||||
entity: Entity;
|
||||
scene: IScene;
|
||||
deltaTime: number;
|
||||
time: number;
|
||||
|
||||
getInput<T>(nodeId: string, pinName: string): T;
|
||||
setOutput(nodeId: string, pinName: string, value: unknown): void;
|
||||
getVariable<T>(name: string): T;
|
||||
setVariable(name: string, value: unknown): void;
|
||||
}
|
||||
```
|
||||
|
||||
### Execution Result
|
||||
|
||||
```typescript
|
||||
interface ExecutionResult {
|
||||
outputs?: Record<string, unknown>; // Output values
|
||||
nextExec?: string | null; // Next exec pin
|
||||
delay?: number; // Delay execution (ms)
|
||||
yield?: boolean; // Pause until next frame
|
||||
error?: string; // Error message
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Nodes
|
||||
|
||||
### Define Node Template
|
||||
|
||||
```typescript
|
||||
import { BlueprintNodeTemplate } from '@esengine/blueprint';
|
||||
|
||||
const MyNodeTemplate: BlueprintNodeTemplate = {
|
||||
type: 'MyCustomNode',
|
||||
title: 'My Custom Node',
|
||||
category: 'custom',
|
||||
description: 'A custom node example',
|
||||
keywords: ['custom', 'example'],
|
||||
inputs: [
|
||||
{ name: 'exec', type: 'exec', direction: 'input', isExec: true },
|
||||
{ name: 'value', type: 'number', direction: 'input', defaultValue: 0 }
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'exec', type: 'exec', direction: 'output', isExec: true },
|
||||
{ name: 'result', type: 'number', direction: 'output' }
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
### Implement Node Executor
|
||||
|
||||
```typescript
|
||||
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
|
||||
|
||||
@RegisterNode(MyNodeTemplate)
|
||||
class MyNodeExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
const value = context.getInput<number>(node.id, 'value');
|
||||
const result = value * 2;
|
||||
|
||||
return {
|
||||
outputs: { result },
|
||||
nextExec: 'exec'
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Registration Methods
|
||||
|
||||
```typescript
|
||||
// Method 1: Decorator
|
||||
@RegisterNode(MyNodeTemplate)
|
||||
class MyNodeExecutor implements INodeExecutor { ... }
|
||||
|
||||
// Method 2: Manual registration
|
||||
NodeRegistry.instance.register(MyNodeTemplate, new MyNodeExecutor());
|
||||
```
|
||||
|
||||
## Node Registry
|
||||
|
||||
```typescript
|
||||
import { NodeRegistry } from '@esengine/blueprint';
|
||||
|
||||
const registry = NodeRegistry.instance;
|
||||
|
||||
const allTemplates = registry.getAllTemplates();
|
||||
const mathNodes = registry.getTemplatesByCategory('math');
|
||||
const results = registry.searchTemplates('add');
|
||||
|
||||
if (registry.has('MyCustomNode')) { ... }
|
||||
```
|
||||
|
||||
## Built-in Nodes
|
||||
|
||||
### Event Nodes
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `EventBeginPlay` | Triggered on blueprint start |
|
||||
| `EventTick` | Triggered every frame |
|
||||
| `EventEndPlay` | Triggered on blueprint stop |
|
||||
| `EventCollision` | Triggered on collision |
|
||||
| `EventInput` | Triggered on input |
|
||||
| `EventTimer` | Triggered by timer |
|
||||
|
||||
### Time Nodes
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `Delay` | Delay execution |
|
||||
| `GetDeltaTime` | Get frame delta |
|
||||
| `GetTime` | Get total runtime |
|
||||
|
||||
### Math Nodes
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `Add`, `Subtract`, `Multiply`, `Divide` | Basic operations |
|
||||
| `Abs`, `Clamp`, `Lerp`, `Min`, `Max` | Utility functions |
|
||||
|
||||
### Debug Nodes
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `Print` | Print to console |
|
||||
|
||||
## Blueprint Composition
|
||||
|
||||
### Blueprint Fragments
|
||||
|
||||
Encapsulate reusable logic as fragments:
|
||||
|
||||
```typescript
|
||||
import { createFragment } from '@esengine/blueprint';
|
||||
|
||||
const healthFragment = createFragment('HealthSystem', {
|
||||
inputs: [
|
||||
{ name: 'damage', type: 'number', internalNodeId: 'input1', internalPinName: 'value' }
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'isDead', type: 'boolean', internalNodeId: 'output1', internalPinName: 'value' }
|
||||
],
|
||||
graph: { nodes: [...], connections: [...], variables: [...] }
|
||||
});
|
||||
```
|
||||
|
||||
### Compose Blueprints
|
||||
|
||||
```typescript
|
||||
import { createComposer, FragmentRegistry } from '@esengine/blueprint';
|
||||
|
||||
// Register fragments
|
||||
FragmentRegistry.instance.register('health', healthFragment);
|
||||
FragmentRegistry.instance.register('movement', movementFragment);
|
||||
|
||||
// Create composer
|
||||
const composer = createComposer('PlayerBlueprint');
|
||||
|
||||
// Add fragments to slots
|
||||
composer.addFragment(healthFragment, 'slot1', { position: { x: 0, y: 0 } });
|
||||
composer.addFragment(movementFragment, 'slot2', { position: { x: 400, y: 0 } });
|
||||
|
||||
// Connect slots
|
||||
composer.connect('slot1', 'onDeath', 'slot2', 'disable');
|
||||
|
||||
// Validate
|
||||
const validation = composer.validate();
|
||||
if (!validation.isValid) {
|
||||
console.error(validation.errors);
|
||||
}
|
||||
|
||||
// Compile to blueprint
|
||||
const blueprint = composer.compile();
|
||||
```
|
||||
|
||||
## Trigger System
|
||||
|
||||
### Define Trigger Conditions
|
||||
|
||||
```typescript
|
||||
import { TriggerCondition, TriggerDispatcher } from '@esengine/blueprint';
|
||||
|
||||
const lowHealthCondition: TriggerCondition = {
|
||||
type: 'comparison',
|
||||
left: { type: 'variable', name: 'health' },
|
||||
operator: '<',
|
||||
right: { type: 'constant', value: 20 }
|
||||
};
|
||||
```
|
||||
|
||||
### Use Trigger Dispatcher
|
||||
|
||||
```typescript
|
||||
const dispatcher = new TriggerDispatcher();
|
||||
|
||||
dispatcher.register('lowHealth', lowHealthCondition, (context) => {
|
||||
context.triggerEvent('OnLowHealth');
|
||||
});
|
||||
|
||||
dispatcher.evaluate(context);
|
||||
```
|
||||
|
||||
## ECS Integration
|
||||
|
||||
### Using Blueprint System
|
||||
|
||||
```typescript
|
||||
import { createBlueprintSystem } from '@esengine/blueprint';
|
||||
|
||||
class GameScene {
|
||||
private blueprintSystem: BlueprintSystem;
|
||||
|
||||
initialize() {
|
||||
this.blueprintSystem = createBlueprintSystem(this.scene);
|
||||
}
|
||||
|
||||
update(dt: number) {
|
||||
this.blueprintSystem.process(this.entities, dt);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Triggering Blueprint Events
|
||||
|
||||
```typescript
|
||||
import { triggerBlueprintEvent, triggerCustomBlueprintEvent } from '@esengine/blueprint';
|
||||
|
||||
triggerBlueprintEvent(entity, 'Collision', { other: otherEntity });
|
||||
triggerCustomBlueprintEvent(entity, 'OnPickup', { item: itemEntity });
|
||||
```
|
||||
|
||||
## Serialization
|
||||
|
||||
### Save Blueprint
|
||||
|
||||
```typescript
|
||||
import { validateBlueprintAsset } from '@esengine/blueprint';
|
||||
|
||||
function saveBlueprint(blueprint: BlueprintAsset, path: string): void {
|
||||
if (!validateBlueprintAsset(blueprint)) {
|
||||
throw new Error('Invalid blueprint structure');
|
||||
}
|
||||
const json = JSON.stringify(blueprint, null, 2);
|
||||
fs.writeFileSync(path, json);
|
||||
}
|
||||
```
|
||||
|
||||
### Load Blueprint
|
||||
|
||||
```typescript
|
||||
async function loadBlueprint(path: string): Promise<BlueprintAsset> {
|
||||
const json = await fs.readFile(path, 'utf-8');
|
||||
const asset = JSON.parse(json);
|
||||
|
||||
if (!validateBlueprintAsset(asset)) {
|
||||
throw new Error('Invalid blueprint file');
|
||||
}
|
||||
|
||||
return asset;
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use fragments for reusable logic**
|
||||
2. **Choose appropriate variable scopes**
|
||||
- `local`: Temporary calculations
|
||||
- `instance`: Entity state (e.g., health)
|
||||
- `global`: Game-wide state
|
||||
3. **Avoid infinite loops** - VM has max steps per frame (default 1000)
|
||||
4. **Debug techniques**
|
||||
- Enable `vm.debug = true` for execution logs
|
||||
- Use Print nodes for intermediate values
|
||||
5. **Performance optimization**
|
||||
- Pure nodes (`isPure: true`) cache outputs
|
||||
- Avoid heavy computation in Tick
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Virtual Machine API](./vm) - BlueprintVM execution and context
|
||||
- [Custom Nodes](./custom-nodes) - Creating custom nodes
|
||||
- [Built-in Nodes](./nodes) - Built-in node reference
|
||||
- [Blueprint Composition](./composition) - Fragments and composer
|
||||
- [Examples](./examples) - ECS integration and best practices
|
||||
## Documentation Navigation
|
||||
|
||||
- [Editor User Guide](./editor-guide) - Cocos Creator Blueprint Editor tutorial
|
||||
- [Virtual Machine API](./vm) - BlueprintVM and ECS integration
|
||||
- [ECS Node Reference](./nodes) - Built-in ECS operation nodes
|
||||
- [Custom Nodes](./custom-nodes) - Create custom ECS nodes
|
||||
- [Blueprint Composition](./composition) - Fragment reuse
|
||||
- [Examples](./examples) - ECS game logic examples
|
||||
|
||||
@@ -1,107 +1,609 @@
|
||||
---
|
||||
title: "Built-in Nodes"
|
||||
description: "Blueprint built-in node reference"
|
||||
title: "ECS Node Reference"
|
||||
description: "Blueprint built-in ECS operation nodes - complete reference with visual examples"
|
||||
---
|
||||
|
||||
This document provides a complete reference for all built-in blueprint nodes with visual examples.
|
||||
|
||||
<script src="/js/blueprint-graph.js"></script>
|
||||
|
||||
## Pin Type Legend
|
||||
|
||||
<div class="bp-legend">
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff" stroke="#fff" stroke-width="1"/></svg> Execution Flow</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#00a0e0" stroke-width="2"/></svg> Entity</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#7030c0" stroke-width="2"/></svg> Component</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#7ecd32" stroke-width="2"/></svg> Number</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#e060e0" stroke-width="2"/></svg> String</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#8c0000" stroke-width="2"/></svg> Boolean</div>
|
||||
</div>
|
||||
|
||||
## Event Nodes
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `EventBeginPlay` | Triggered when blueprint starts |
|
||||
| `EventTick` | Triggered each frame |
|
||||
| `EventEndPlay` | Triggered when blueprint stops |
|
||||
| `EventCollision` | Triggered on collision |
|
||||
| `EventInput` | Triggered on input event |
|
||||
| `EventTimer` | Triggered by timer |
|
||||
| `EventMessage` | Triggered by custom message |
|
||||
Lifecycle events as blueprint entry points:
|
||||
|
||||
## Flow Control Nodes
|
||||
| Node | Description | Outputs |
|
||||
|------|-------------|---------|
|
||||
| `EventBeginPlay` | Triggered when blueprint starts | Exec, Self (Entity) |
|
||||
| `EventTick` | Triggered each frame | Exec, Delta Time |
|
||||
| `EventEndPlay` | Triggered when blueprint stops | Exec |
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `Branch` | Conditional branch (if/else) |
|
||||
| `Sequence` | Execute multiple outputs in sequence |
|
||||
| `ForLoop` | Loop execution |
|
||||
| `WhileLoop` | Conditional loop |
|
||||
| `DoOnce` | Execute only once |
|
||||
| `FlipFlop` | Alternate between two branches |
|
||||
| `Gate` | Toggleable execution gate |
|
||||
### Example: Game Initialization
|
||||
|
||||
## Time Nodes
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-beginplay-exec","to":"en-print-exec","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="en-beginplay-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Self</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-print-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Message</span>
|
||||
<span class="bp-pin-value">"Game Started!"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `Delay` | Delay execution |
|
||||
| `GetDeltaTime` | Get frame delta time |
|
||||
| `GetTime` | Get runtime |
|
||||
| `SetTimer` | Set timer |
|
||||
| `ClearTimer` | Clear timer |
|
||||
### Example: Per-Frame Movement
|
||||
|
||||
## Math Nodes
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `Add` | Addition |
|
||||
| `Subtract` | Subtraction |
|
||||
| `Multiply` | Multiplication |
|
||||
| `Divide` | Division |
|
||||
| `Abs` | Absolute value |
|
||||
| `Clamp` | Clamp to range |
|
||||
| `Lerp` | Linear interpolation |
|
||||
| `Min` / `Max` | Minimum/Maximum |
|
||||
| `Sin` / `Cos` | Trigonometric functions |
|
||||
| `Sqrt` | Square root |
|
||||
| `Power` | Power |
|
||||
|
||||
## Logic Nodes
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `And` | Logical AND |
|
||||
| `Or` | Logical OR |
|
||||
| `Not` | Logical NOT |
|
||||
| `Equal` | Equality comparison |
|
||||
| `NotEqual` | Inequality comparison |
|
||||
| `Greater` | Greater than comparison |
|
||||
| `Less` | Less than comparison |
|
||||
|
||||
## Vector Nodes
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `MakeVector2` | Create 2D vector |
|
||||
| `BreakVector2` | Break 2D vector |
|
||||
| `VectorAdd` | Vector addition |
|
||||
| `VectorSubtract` | Vector subtraction |
|
||||
| `VectorMultiply` | Vector multiplication |
|
||||
| `VectorLength` | Vector length |
|
||||
| `VectorNormalize` | Vector normalization |
|
||||
| `VectorDistance` | Vector distance |
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-tick-exec","to":"en-setprop-exec","type":"exec"},{"from":"en-tick-delta","to":"en-mul-a","type":"float"},{"from":"en-mul-result","to":"en-setprop-x","type":"float"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event Tick</span>
|
||||
<span class="bp-header-exec" data-pin="en-tick-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-tick-delta"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Delta Time</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 200px; top: 110px; width: 120px;">
|
||||
<div class="bp-node-header math">Multiply</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-mul-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
<span class="bp-pin-value">100</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-mul-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 380px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Set Property</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-setprop-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7030c0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Target</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-setprop-x"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Entity Nodes
|
||||
|
||||
Manipulate ECS entities:
|
||||
|
||||
| Node | Description | Type |
|
||||
|------|-------------|------|
|
||||
| `Get Self` | Get the entity owning this blueprint | Pure |
|
||||
| `Create Entity` | Create a new entity in the scene | Exec |
|
||||
| `Destroy Entity` | Destroy specified entity | Exec |
|
||||
| `Destroy Self` | Destroy the owning entity | Exec |
|
||||
| `Is Valid` | Check if entity is valid | Pure |
|
||||
| `Get Entity Name` | Get entity name | Pure |
|
||||
| `Set Entity Name` | Set entity name | Exec |
|
||||
| `Find Entity By Name` | Find entity by name | Pure |
|
||||
| `Find Entities By Tag` | Find all entities with tag | Pure |
|
||||
|
||||
### Example: Create Bullet
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-bp-exec","to":"en-create-exec","type":"exec"},{"from":"en-create-exec-out","to":"en-add-exec","type":"exec"},{"from":"en-create-entity","to":"en-add-entity","type":"entity"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="en-bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Self</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Create Entity</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-create-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-create-exec-out"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-create-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 520px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Add Transform</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-add-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-add-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Component Nodes
|
||||
|
||||
Read and write component properties:
|
||||
|
||||
| Node | Description | Type |
|
||||
|------|-------------|------|
|
||||
| `Get Component` | Get component of specified type from entity | Pure |
|
||||
| `Has Component` | Check if entity has specified component | Pure |
|
||||
| `Add Component` | Add component to entity | Exec |
|
||||
| `Remove Component` | Remove component from entity | Exec |
|
||||
| `Get Property` | Get component property value | Pure |
|
||||
| `Set Property` | Set component property value | Exec |
|
||||
|
||||
### Example: Modify Position
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-self-entity","to":"en-getcomp-entity","type":"entity"},{"from":"en-getcomp-transform","to":"en-getprop-target","type":"component"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 100px;">
|
||||
<div class="bp-node-header pure">Get Self</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-self-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 200px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header pure">Get Component</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-getcomp-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-getcomp-transform"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
|
||||
<span class="bp-pin-label">Transform</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 430px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header pure">Get Property</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-getprop-target"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
|
||||
<span class="bp-pin-label">Target</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Flow Control Nodes
|
||||
|
||||
Control blueprint execution flow:
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `GetSelf` | Get current entity |
|
||||
| `GetComponent` | Get component |
|
||||
| `HasComponent` | Check component |
|
||||
| `AddComponent` | Add component |
|
||||
| `RemoveComponent` | Remove component |
|
||||
| `SpawnEntity` | Create entity |
|
||||
| `DestroyEntity` | Destroy entity |
|
||||
| `Branch` | Conditional branching (if/else) |
|
||||
| `Sequence` | Execute multiple branches in order |
|
||||
| `For Loop` | Loop specified number of times |
|
||||
| `For Each` | Iterate over array elements |
|
||||
| `While Loop` | Loop while condition is true |
|
||||
| `Do Once` | Execute only once |
|
||||
| `Flip Flop` | Alternate between A and B |
|
||||
| `Gate` | Gate switch control |
|
||||
|
||||
### Example: Conditional Branch
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-cond-exec","to":"en-branch-exec","type":"exec"},{"from":"en-cond-result","to":"en-branch-cond","type":"bool"},{"from":"en-branch-true","to":"en-print1-exec","type":"exec"},{"from":"en-branch-false","to":"en-print2-exec","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 60px; width: 120px;">
|
||||
<div class="bp-node-header pure">Condition</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-cond-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-cond-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#8c0000"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 220px; top: 60px; width: 110px;">
|
||||
<div class="bp-node-header flow">Branch</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-branch-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-branch-cond"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#8c0000"/></svg></span>
|
||||
<span class="bp-pin-label">Cond</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-branch-true"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">True</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-branch-false"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">False</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 420px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-print1-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Msg</span>
|
||||
<span class="bp-pin-value">"Yes"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 420px; top: 130px; width: 120px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-print2-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Msg</span>
|
||||
<span class="bp-pin-value">"No"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Example: For Loop
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-forloop-bp-exec","to":"en-forloop-exec","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="en-forloop-bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header flow">For Loop</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-forloop-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">First</span>
|
||||
<span class="bp-pin-value">0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Last</span>
|
||||
<span class="bp-pin-value">10</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Body</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">Index</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Time Nodes
|
||||
|
||||
| Node | Description | Output |
|
||||
|------|-------------|--------|
|
||||
| `Delay` | Delay execution by specified seconds | Exec |
|
||||
| `Get Delta Time` | Get frame delta time | Float |
|
||||
| `Get Time` | Get total runtime | Float |
|
||||
|
||||
### Example: Delayed Execution
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-delay-bp-exec","to":"en-delay-exec","type":"exec"},{"from":"en-delay-done","to":"en-delay-print-exec","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="en-delay-bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header time">Delay</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-delay-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Duration</span>
|
||||
<span class="bp-pin-value">2.0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-delay-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 490px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-delay-print-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Msg</span>
|
||||
<span class="bp-pin-value">"After 2s"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Math Nodes
|
||||
|
||||
### Basic Operations
|
||||
|
||||
| Node | Description | Inputs | Output |
|
||||
|------|-------------|--------|--------|
|
||||
| `Add` | Addition | A, B | A + B |
|
||||
| `Subtract` | Subtraction | A, B | A - B |
|
||||
| `Multiply` | Multiplication | A, B | A × B |
|
||||
| `Divide` | Division | A, B | A / B |
|
||||
| `Modulo` | Modulo | A, B | A % B |
|
||||
|
||||
### Math Functions
|
||||
|
||||
| Node | Description | Inputs | Output |
|
||||
|------|-------------|--------|--------|
|
||||
| `Abs` | Absolute value | Value | \|Value\| |
|
||||
| `Sqrt` | Square root | Value | √Value |
|
||||
| `Pow` | Power | Base, Exp | Base^Exp |
|
||||
| `Floor` | Floor | Value | ⌊Value⌋ |
|
||||
| `Ceil` | Ceiling | Value | ⌈Value⌉ |
|
||||
| `Round` | Round | Value | round(Value) |
|
||||
| `Clamp` | Clamp to range | Value, Min, Max | min(max(V, Min), Max) |
|
||||
| `Lerp` | Linear interpolation | A, B, Alpha | A + (B-A) × Alpha |
|
||||
| `Min` | Minimum | A, B | min(A, B) |
|
||||
| `Max` | Maximum | A, B | max(A, B) |
|
||||
|
||||
### Trigonometric Functions
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `Sin` | Sine |
|
||||
| `Cos` | Cosine |
|
||||
| `Tan` | Tangent |
|
||||
| `Asin` | Arc sine |
|
||||
| `Acos` | Arc cosine |
|
||||
| `Atan` | Arc tangent |
|
||||
| `Atan2` | Two-argument arc tangent |
|
||||
|
||||
### Random Numbers
|
||||
|
||||
| Node | Description | Inputs | Output |
|
||||
|------|-------------|--------|--------|
|
||||
| `Random` | Random float [0, 1) | - | Float |
|
||||
| `Random Range` | Random in range | Min, Max | Float |
|
||||
| `Random Int` | Random integer | Min, Max | Int |
|
||||
|
||||
### Comparison Nodes
|
||||
|
||||
| Node | Description | Output |
|
||||
|------|-------------|--------|
|
||||
| `Equal` | A == B | Boolean |
|
||||
| `Not Equal` | A != B | Boolean |
|
||||
| `Greater` | A > B | Boolean |
|
||||
| `Greater Or Equal` | A >= B | Boolean |
|
||||
| `Less` | A < B | Boolean |
|
||||
| `Less Or Equal` | A <= B | Boolean |
|
||||
|
||||
### Example: Clamp Value
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-rand-result","to":"en-clamp-value","type":"float"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header math">Random Range</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Min</span>
|
||||
<span class="bp-pin-value">0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Max</span>
|
||||
<span class="bp-pin-value">100</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-rand-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 240px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header math">Clamp</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-clamp-value"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Value</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Min</span>
|
||||
<span class="bp-pin-value">20</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Max</span>
|
||||
<span class="bp-pin-value">80</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Variable Nodes
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `GetVariable` | Get variable value |
|
||||
| `SetVariable` | Set variable value |
|
||||
Blueprint-defined variables automatically generate Get and Set nodes:
|
||||
|
||||
| Node | Description | Type |
|
||||
|------|-------------|------|
|
||||
| `Get <varname>` | Read variable value | Pure |
|
||||
| `Set <varname>` | Set variable value | Exec |
|
||||
|
||||
### Example: Counter
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"en-cnt-tick-exec","to":"en-cnt-add-exec","type":"exec"},{"from":"en-cnt-get-value","to":"en-cnt-add-a","type":"int"},{"from":"en-cnt-add-result","to":"en-cnt-set-value","type":"int"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event Tick</span>
|
||||
<span class="bp-header-exec" data-pin="en-cnt-tick-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Delta</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 20px; top: 120px; width: 110px;">
|
||||
<div class="bp-node-header variable">Get Count</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-cnt-get-value"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">Value</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 240px; top: 20px; width: 110px;">
|
||||
<div class="bp-node-header math">Add</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-cnt-add-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-cnt-add-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
<span class="bp-pin-value">1</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="en-cnt-add-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 430px; top: 20px; width: 110px;">
|
||||
<div class="bp-node-header variable">Set Count</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="en-cnt-set-value"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">Value</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Debug Nodes
|
||||
|
||||
| Node | Description |
|
||||
|------|-------------|
|
||||
| `Print` | Print to console |
|
||||
| `DrawDebugLine` | Draw debug line |
|
||||
| `DrawDebugPoint` | Draw debug point |
|
||||
| `Breakpoint` | Debug breakpoint |
|
||||
| `Print` | Output message to console |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Blueprint Editor Guide](/en/modules/blueprint/editor-guide) - Learn how to use the editor
|
||||
- [Custom Nodes](/en/modules/blueprint/custom-nodes) - Create custom nodes
|
||||
- [Blueprint VM](/en/modules/blueprint/vm) - Runtime API
|
||||
|
||||
@@ -45,7 +45,7 @@ interface ExecutionContext {
|
||||
time: number; // Total runtime
|
||||
|
||||
// Get input value
|
||||
getInput<T>(nodeId: string, pinName: string): T;
|
||||
evaluateInput(nodeId: string, pinName: string, defaultValue: unknown): unknown;
|
||||
|
||||
// Set output value
|
||||
setOutput(nodeId: string, pinName: string, value: unknown): void;
|
||||
@@ -70,35 +70,33 @@ interface ExecutionResult {
|
||||
|
||||
## ECS Integration
|
||||
|
||||
### Using Blueprint System
|
||||
### Using Built-in Blueprint System
|
||||
|
||||
```typescript
|
||||
import { createBlueprintSystem } from '@esengine/blueprint';
|
||||
import { Scene, Core } from '@esengine/ecs-framework';
|
||||
import { BlueprintSystem, BlueprintComponent } from '@esengine/blueprint';
|
||||
|
||||
class GameScene {
|
||||
private blueprintSystem: BlueprintSystem;
|
||||
// Add blueprint system to scene
|
||||
const scene = new Scene();
|
||||
scene.addSystem(new BlueprintSystem());
|
||||
Core.setScene(scene);
|
||||
|
||||
initialize() {
|
||||
this.blueprintSystem = createBlueprintSystem(this.scene);
|
||||
}
|
||||
|
||||
update(dt: number) {
|
||||
// Process all entities with blueprint components
|
||||
this.blueprintSystem.process(this.entities, dt);
|
||||
}
|
||||
}
|
||||
// Add blueprint to entity
|
||||
const entity = scene.createEntity('Player');
|
||||
const blueprint = new BlueprintComponent();
|
||||
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
|
||||
entity.addComponent(blueprint);
|
||||
```
|
||||
|
||||
### Triggering Blueprint Events
|
||||
|
||||
```typescript
|
||||
import { triggerBlueprintEvent, triggerCustomBlueprintEvent } from '@esengine/blueprint';
|
||||
|
||||
// Trigger built-in event
|
||||
triggerBlueprintEvent(entity, 'Collision', { other: otherEntity });
|
||||
|
||||
// Trigger custom event
|
||||
triggerCustomBlueprintEvent(entity, 'OnPickup', { item: itemEntity });
|
||||
// Get blueprint component from entity and trigger events
|
||||
const blueprint = entity.getComponent(BlueprintComponent);
|
||||
if (blueprint?.vm) {
|
||||
blueprint.vm.triggerEvent('EventCollision', { other: otherEntity });
|
||||
blueprint.vm.triggerCustomEvent('OnPickup', { item: itemEntity });
|
||||
}
|
||||
```
|
||||
|
||||
## Serialization
|
||||
|
||||
136
docs/src/content/docs/en/modules/database-drivers/index.md
Normal file
136
docs/src/content/docs/en/modules/database-drivers/index.md
Normal file
@@ -0,0 +1,136 @@
|
||||
---
|
||||
title: "Database Drivers"
|
||||
description: "MongoDB, Redis connection management and driver abstraction"
|
||||
---
|
||||
|
||||
`@esengine/database-drivers` is ESEngine's database connection management layer, providing unified connection management for MongoDB, Redis, and more.
|
||||
|
||||
## Features
|
||||
|
||||
- **Connection Pool** - Automatic connection pool management
|
||||
- **Auto Reconnect** - Automatic reconnection on disconnect
|
||||
- **Event Notification** - Connection state change events
|
||||
- **Type Decoupling** - Simplified interfaces, no dependency on native driver types
|
||||
- **Shared Connections** - Single connection shared across modules
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/database-drivers
|
||||
```
|
||||
|
||||
**Peer Dependencies:**
|
||||
```bash
|
||||
npm install mongodb # For MongoDB support
|
||||
npm install ioredis # For Redis support
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ @esengine/database-drivers (Layer 1) │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ MongoConnection │ │ RedisConnection │ │
|
||||
│ │ - Pool management │ │ - Auto-reconnect │ │
|
||||
│ │ - Auto-reconnect │ │ - Key prefix │ │
|
||||
│ │ - Event emitter │ │ - Event emitter │ │
|
||||
│ └──────────┬──────────┘ └─────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ IMongoCollection<T> │ ← Type-safe interface │
|
||||
│ │ (Adapter pattern) │ decoupled from mongodb types │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐ ┌───────────────────────┐
|
||||
│ @esengine/database │ │ @esengine/transaction │
|
||||
│ (Repository pattern) │ │ (Distributed tx) │
|
||||
└───────────────────────┘ └───────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### MongoDB Connection
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
|
||||
// Create connection
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game',
|
||||
pool: {
|
||||
minSize: 5,
|
||||
maxSize: 20
|
||||
},
|
||||
autoReconnect: true
|
||||
})
|
||||
|
||||
// Listen to events
|
||||
mongo.on('connected', () => console.log('MongoDB connected'))
|
||||
mongo.on('disconnected', () => console.log('MongoDB disconnected'))
|
||||
mongo.on('error', (e) => console.error('Error:', e.error))
|
||||
|
||||
// Connect
|
||||
await mongo.connect()
|
||||
|
||||
// Use collections
|
||||
const users = mongo.collection<User>('users')
|
||||
await users.insertOne({ name: 'John', score: 100 })
|
||||
|
||||
const user = await users.findOne({ name: 'John' })
|
||||
|
||||
// Disconnect when done
|
||||
await mongo.disconnect()
|
||||
```
|
||||
|
||||
### Redis Connection
|
||||
|
||||
```typescript
|
||||
import { createRedisConnection } from '@esengine/database-drivers'
|
||||
|
||||
const redis = createRedisConnection({
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
keyPrefix: 'game:',
|
||||
autoReconnect: true
|
||||
})
|
||||
|
||||
await redis.connect()
|
||||
|
||||
// Basic operations
|
||||
await redis.set('session:123', 'data', 3600) // With TTL
|
||||
const value = await redis.get('session:123')
|
||||
|
||||
await redis.disconnect()
|
||||
```
|
||||
|
||||
## Service Container Integration
|
||||
|
||||
```typescript
|
||||
import { ServiceContainer } from '@esengine/ecs-framework'
|
||||
import {
|
||||
createMongoConnection,
|
||||
MongoConnectionToken,
|
||||
RedisConnectionToken
|
||||
} from '@esengine/database-drivers'
|
||||
|
||||
const services = new ServiceContainer()
|
||||
|
||||
// Register connections
|
||||
const mongo = createMongoConnection({ uri: '...', database: 'game' })
|
||||
await mongo.connect()
|
||||
services.register(MongoConnectionToken, mongo)
|
||||
|
||||
// Retrieve in other modules
|
||||
const connection = services.get(MongoConnectionToken)
|
||||
const users = connection.collection('users')
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [MongoDB Connection](/en/modules/database-drivers/mongo/) - MongoDB configuration details
|
||||
- [Redis Connection](/en/modules/database-drivers/redis/) - Redis configuration details
|
||||
- [Service Tokens](/en/modules/database-drivers/tokens/) - Dependency injection integration
|
||||
265
docs/src/content/docs/en/modules/database-drivers/mongo.md
Normal file
265
docs/src/content/docs/en/modules/database-drivers/mongo.md
Normal file
@@ -0,0 +1,265 @@
|
||||
---
|
||||
title: "MongoDB Connection"
|
||||
description: "MongoDB connection management, connection pooling, auto-reconnect"
|
||||
---
|
||||
|
||||
## Configuration Options
|
||||
|
||||
```typescript
|
||||
interface MongoConnectionConfig {
|
||||
/** MongoDB connection URI */
|
||||
uri: string
|
||||
|
||||
/** Database name */
|
||||
database: string
|
||||
|
||||
/** Connection pool configuration */
|
||||
pool?: {
|
||||
minSize?: number // Minimum connections
|
||||
maxSize?: number // Maximum connections
|
||||
acquireTimeout?: number // Connection acquire timeout (ms)
|
||||
maxLifetime?: number // Maximum connection lifetime (ms)
|
||||
}
|
||||
|
||||
/** Auto-reconnect (default true) */
|
||||
autoReconnect?: boolean
|
||||
|
||||
/** Reconnect interval (ms, default 5000) */
|
||||
reconnectInterval?: number
|
||||
|
||||
/** Maximum reconnect attempts (default 10) */
|
||||
maxReconnectAttempts?: number
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection, MongoConnectionToken } from '@esengine/database-drivers'
|
||||
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game',
|
||||
pool: {
|
||||
minSize: 5,
|
||||
maxSize: 20,
|
||||
acquireTimeout: 5000,
|
||||
maxLifetime: 300000
|
||||
},
|
||||
autoReconnect: true,
|
||||
reconnectInterval: 5000,
|
||||
maxReconnectAttempts: 10
|
||||
})
|
||||
|
||||
// Event listeners
|
||||
mongo.on('connected', () => {
|
||||
console.log('MongoDB connected')
|
||||
})
|
||||
|
||||
mongo.on('disconnected', () => {
|
||||
console.log('MongoDB disconnected')
|
||||
})
|
||||
|
||||
mongo.on('reconnecting', () => {
|
||||
console.log('MongoDB reconnecting...')
|
||||
})
|
||||
|
||||
mongo.on('reconnected', () => {
|
||||
console.log('MongoDB reconnected')
|
||||
})
|
||||
|
||||
mongo.on('error', (event) => {
|
||||
console.error('MongoDB error:', event.error)
|
||||
})
|
||||
|
||||
// Connect
|
||||
await mongo.connect()
|
||||
|
||||
// Check status
|
||||
console.log('Connected:', mongo.isConnected())
|
||||
console.log('Ping:', await mongo.ping())
|
||||
```
|
||||
|
||||
## IMongoConnection Interface
|
||||
|
||||
```typescript
|
||||
interface IMongoConnection {
|
||||
/** Connection ID */
|
||||
readonly id: string
|
||||
|
||||
/** Connection state */
|
||||
readonly state: ConnectionState
|
||||
|
||||
/** Establish connection */
|
||||
connect(): Promise<void>
|
||||
|
||||
/** Disconnect */
|
||||
disconnect(): Promise<void>
|
||||
|
||||
/** Check if connected */
|
||||
isConnected(): boolean
|
||||
|
||||
/** Test connection */
|
||||
ping(): Promise<boolean>
|
||||
|
||||
/** Get typed collection */
|
||||
collection<T extends object>(name: string): IMongoCollection<T>
|
||||
|
||||
/** Get database interface */
|
||||
getDatabase(): IMongoDatabase
|
||||
|
||||
/** Get native client (advanced usage) */
|
||||
getNativeClient(): MongoClientType
|
||||
|
||||
/** Get native database (advanced usage) */
|
||||
getNativeDatabase(): Db
|
||||
}
|
||||
```
|
||||
|
||||
## IMongoCollection Interface
|
||||
|
||||
Type-safe collection interface, decoupled from native MongoDB types:
|
||||
|
||||
```typescript
|
||||
interface IMongoCollection<T extends object> {
|
||||
readonly name: string
|
||||
|
||||
// Query
|
||||
findOne(filter: object, options?: FindOptions): Promise<T | null>
|
||||
find(filter: object, options?: FindOptions): Promise<T[]>
|
||||
countDocuments(filter?: object): Promise<number>
|
||||
|
||||
// Insert
|
||||
insertOne(doc: T): Promise<InsertOneResult>
|
||||
insertMany(docs: T[]): Promise<InsertManyResult>
|
||||
|
||||
// Update
|
||||
updateOne(filter: object, update: object): Promise<UpdateResult>
|
||||
updateMany(filter: object, update: object): Promise<UpdateResult>
|
||||
findOneAndUpdate(
|
||||
filter: object,
|
||||
update: object,
|
||||
options?: FindOneAndUpdateOptions
|
||||
): Promise<T | null>
|
||||
|
||||
// Delete
|
||||
deleteOne(filter: object): Promise<DeleteResult>
|
||||
deleteMany(filter: object): Promise<DeleteResult>
|
||||
|
||||
// Index
|
||||
createIndex(
|
||||
spec: Record<string, 1 | -1>,
|
||||
options?: IndexOptions
|
||||
): Promise<string>
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic CRUD
|
||||
|
||||
```typescript
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
score: number
|
||||
}
|
||||
|
||||
const users = mongo.collection<User>('users')
|
||||
|
||||
// Insert
|
||||
await users.insertOne({
|
||||
id: '1',
|
||||
name: 'John',
|
||||
email: 'john@example.com',
|
||||
score: 100
|
||||
})
|
||||
|
||||
// Query
|
||||
const user = await users.findOne({ name: 'John' })
|
||||
|
||||
const topUsers = await users.find(
|
||||
{ score: { $gte: 100 } },
|
||||
{ sort: { score: -1 }, limit: 10 }
|
||||
)
|
||||
|
||||
// Update
|
||||
await users.updateOne(
|
||||
{ id: '1' },
|
||||
{ $inc: { score: 10 } }
|
||||
)
|
||||
|
||||
// Delete
|
||||
await users.deleteOne({ id: '1' })
|
||||
```
|
||||
|
||||
### Batch Operations
|
||||
|
||||
```typescript
|
||||
// Batch insert
|
||||
await users.insertMany([
|
||||
{ id: '1', name: 'Alice', email: 'alice@example.com', score: 100 },
|
||||
{ id: '2', name: 'Bob', email: 'bob@example.com', score: 200 },
|
||||
{ id: '3', name: 'Carol', email: 'carol@example.com', score: 150 }
|
||||
])
|
||||
|
||||
// Batch update
|
||||
await users.updateMany(
|
||||
{ score: { $lt: 100 } },
|
||||
{ $set: { status: 'inactive' } }
|
||||
)
|
||||
|
||||
// Batch delete
|
||||
await users.deleteMany({ status: 'inactive' })
|
||||
```
|
||||
|
||||
### Index Management
|
||||
|
||||
```typescript
|
||||
// Create indexes
|
||||
await users.createIndex({ email: 1 }, { unique: true })
|
||||
await users.createIndex({ score: -1 })
|
||||
await users.createIndex({ name: 1, score: -1 })
|
||||
```
|
||||
|
||||
## Integration with Other Modules
|
||||
|
||||
### With @esengine/database
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
import { UserRepository, createRepository } from '@esengine/database'
|
||||
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
})
|
||||
await mongo.connect()
|
||||
|
||||
// Use UserRepository
|
||||
const userRepo = new UserRepository(mongo)
|
||||
await userRepo.register({ username: 'john', password: '123456' })
|
||||
|
||||
// Use generic repository
|
||||
const playerRepo = createRepository<Player>(mongo, 'players')
|
||||
```
|
||||
|
||||
### With @esengine/transaction
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
import { createMongoStorage, TransactionManager } from '@esengine/transaction'
|
||||
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
})
|
||||
await mongo.connect()
|
||||
|
||||
// Create transaction storage (shared connection)
|
||||
const storage = createMongoStorage(mongo)
|
||||
await storage.ensureIndexes()
|
||||
|
||||
const txManager = new TransactionManager({ storage })
|
||||
```
|
||||
228
docs/src/content/docs/en/modules/database-drivers/redis.md
Normal file
228
docs/src/content/docs/en/modules/database-drivers/redis.md
Normal file
@@ -0,0 +1,228 @@
|
||||
---
|
||||
title: "Redis Connection"
|
||||
description: "Redis connection management, auto-reconnect, key prefix"
|
||||
---
|
||||
|
||||
## Configuration Options
|
||||
|
||||
```typescript
|
||||
interface RedisConnectionConfig {
|
||||
/** Redis host */
|
||||
host?: string
|
||||
|
||||
/** Redis port */
|
||||
port?: number
|
||||
|
||||
/** Authentication password */
|
||||
password?: string
|
||||
|
||||
/** Database number */
|
||||
db?: number
|
||||
|
||||
/** Key prefix */
|
||||
keyPrefix?: string
|
||||
|
||||
/** Auto-reconnect (default true) */
|
||||
autoReconnect?: boolean
|
||||
|
||||
/** Reconnect interval (ms, default 5000) */
|
||||
reconnectInterval?: number
|
||||
|
||||
/** Maximum reconnect attempts (default 10) */
|
||||
maxReconnectAttempts?: number
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
import { createRedisConnection, RedisConnectionToken } from '@esengine/database-drivers'
|
||||
|
||||
const redis = createRedisConnection({
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
password: 'your-password',
|
||||
db: 0,
|
||||
keyPrefix: 'game:',
|
||||
autoReconnect: true,
|
||||
reconnectInterval: 5000,
|
||||
maxReconnectAttempts: 10
|
||||
})
|
||||
|
||||
// Event listeners
|
||||
redis.on('connected', () => {
|
||||
console.log('Redis connected')
|
||||
})
|
||||
|
||||
redis.on('disconnected', () => {
|
||||
console.log('Redis disconnected')
|
||||
})
|
||||
|
||||
redis.on('error', (event) => {
|
||||
console.error('Redis error:', event.error)
|
||||
})
|
||||
|
||||
// Connect
|
||||
await redis.connect()
|
||||
|
||||
// Check status
|
||||
console.log('Connected:', redis.isConnected())
|
||||
console.log('Ping:', await redis.ping())
|
||||
```
|
||||
|
||||
## IRedisConnection Interface
|
||||
|
||||
```typescript
|
||||
interface IRedisConnection {
|
||||
/** Connection ID */
|
||||
readonly id: string
|
||||
|
||||
/** Connection state */
|
||||
readonly state: ConnectionState
|
||||
|
||||
/** Establish connection */
|
||||
connect(): Promise<void>
|
||||
|
||||
/** Disconnect */
|
||||
disconnect(): Promise<void>
|
||||
|
||||
/** Check if connected */
|
||||
isConnected(): boolean
|
||||
|
||||
/** Test connection */
|
||||
ping(): Promise<boolean>
|
||||
|
||||
/** Get value */
|
||||
get(key: string): Promise<string | null>
|
||||
|
||||
/** Set value (optional TTL in seconds) */
|
||||
set(key: string, value: string, ttl?: number): Promise<void>
|
||||
|
||||
/** Delete key */
|
||||
del(key: string): Promise<boolean>
|
||||
|
||||
/** Check if key exists */
|
||||
exists(key: string): Promise<boolean>
|
||||
|
||||
/** Set expiration (seconds) */
|
||||
expire(key: string, seconds: number): Promise<boolean>
|
||||
|
||||
/** Get remaining TTL (seconds) */
|
||||
ttl(key: string): Promise<number>
|
||||
|
||||
/** Get native client (advanced usage) */
|
||||
getNativeClient(): Redis
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Operations
|
||||
|
||||
```typescript
|
||||
// Set value
|
||||
await redis.set('user:1:name', 'John')
|
||||
|
||||
// Set value with expiration (1 hour)
|
||||
await redis.set('session:abc123', 'user-data', 3600)
|
||||
|
||||
// Get value
|
||||
const name = await redis.get('user:1:name')
|
||||
|
||||
// Check if key exists
|
||||
const exists = await redis.exists('user:1:name')
|
||||
|
||||
// Delete key
|
||||
await redis.del('user:1:name')
|
||||
|
||||
// Get remaining TTL
|
||||
const ttl = await redis.ttl('session:abc123')
|
||||
```
|
||||
|
||||
### Key Prefix
|
||||
|
||||
When `keyPrefix` is configured, all operations automatically add the prefix:
|
||||
|
||||
```typescript
|
||||
const redis = createRedisConnection({
|
||||
host: 'localhost',
|
||||
keyPrefix: 'game:'
|
||||
})
|
||||
|
||||
// Actual key is 'game:user:1'
|
||||
await redis.set('user:1', 'data')
|
||||
|
||||
// Actual key queried is 'game:user:1'
|
||||
const data = await redis.get('user:1')
|
||||
```
|
||||
|
||||
### Advanced Operations
|
||||
|
||||
Use native client for advanced operations:
|
||||
|
||||
```typescript
|
||||
const client = redis.getNativeClient()
|
||||
|
||||
// Using Pipeline
|
||||
const pipeline = client.pipeline()
|
||||
pipeline.set('key1', 'value1')
|
||||
pipeline.set('key2', 'value2')
|
||||
pipeline.set('key3', 'value3')
|
||||
await pipeline.exec()
|
||||
|
||||
// Using Transactions
|
||||
const multi = client.multi()
|
||||
multi.incr('counter')
|
||||
multi.get('counter')
|
||||
const results = await multi.exec()
|
||||
|
||||
// Using Lua Scripts
|
||||
const result = await client.eval(
|
||||
`return redis.call('get', KEYS[1])`,
|
||||
1,
|
||||
'mykey'
|
||||
)
|
||||
```
|
||||
|
||||
## Integration with Transaction System
|
||||
|
||||
```typescript
|
||||
import { createRedisConnection } from '@esengine/database-drivers'
|
||||
import { RedisStorage, TransactionManager } from '@esengine/transaction'
|
||||
|
||||
const redis = createRedisConnection({
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
keyPrefix: 'tx:'
|
||||
})
|
||||
await redis.connect()
|
||||
|
||||
// Create transaction storage
|
||||
const storage = new RedisStorage({
|
||||
factory: () => redis.getNativeClient(),
|
||||
prefix: 'tx:'
|
||||
})
|
||||
|
||||
const txManager = new TransactionManager({ storage })
|
||||
```
|
||||
|
||||
## Connection State
|
||||
|
||||
```typescript
|
||||
type ConnectionState =
|
||||
| 'disconnected' // Not connected
|
||||
| 'connecting' // Connecting
|
||||
| 'connected' // Connected
|
||||
| 'disconnecting' // Disconnecting
|
||||
| 'error' // Error state
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `connected` | Connection established |
|
||||
| `disconnected` | Connection closed |
|
||||
| `reconnecting` | Reconnecting |
|
||||
| `reconnected` | Reconnection successful |
|
||||
| `error` | Error occurred |
|
||||
217
docs/src/content/docs/en/modules/database/index.md
Normal file
217
docs/src/content/docs/en/modules/database/index.md
Normal file
@@ -0,0 +1,217 @@
|
||||
---
|
||||
title: "Database Repository"
|
||||
description: "Repository pattern database layer with CRUD, pagination, and soft delete"
|
||||
---
|
||||
|
||||
`@esengine/database` is ESEngine's database operation layer, providing type-safe CRUD operations based on the Repository pattern.
|
||||
|
||||
## Features
|
||||
|
||||
- **Repository Pattern** - Generic CRUD operations with type safety
|
||||
- **Pagination** - Built-in pagination support
|
||||
- **Soft Delete** - Optional soft delete with restore
|
||||
- **User Management** - Ready-to-use UserRepository
|
||||
- **Password Security** - Secure password hashing with scrypt
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/database @esengine/database-drivers
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Repository
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
import { Repository, createRepository } from '@esengine/database'
|
||||
|
||||
// Define entity
|
||||
interface Player {
|
||||
id: string
|
||||
name: string
|
||||
score: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// Create connection
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
})
|
||||
await mongo.connect()
|
||||
|
||||
// Create repository
|
||||
const playerRepo = createRepository<Player>(mongo, 'players')
|
||||
|
||||
// CRUD operations
|
||||
const player = await playerRepo.create({
|
||||
name: 'John',
|
||||
score: 0
|
||||
})
|
||||
|
||||
const found = await playerRepo.findById(player.id)
|
||||
|
||||
await playerRepo.update(player.id, { score: 100 })
|
||||
|
||||
await playerRepo.delete(player.id)
|
||||
```
|
||||
|
||||
### Custom Repository
|
||||
|
||||
```typescript
|
||||
import { Repository, BaseEntity } from '@esengine/database'
|
||||
import type { IMongoConnection } from '@esengine/database-drivers'
|
||||
|
||||
interface Player extends BaseEntity {
|
||||
name: string
|
||||
score: number
|
||||
rank?: string
|
||||
}
|
||||
|
||||
class PlayerRepository extends Repository<Player> {
|
||||
constructor(connection: IMongoConnection) {
|
||||
super(connection, 'players')
|
||||
}
|
||||
|
||||
async findTopPlayers(limit: number = 10): Promise<Player[]> {
|
||||
return this.findMany({
|
||||
sort: { score: 'desc' },
|
||||
limit
|
||||
})
|
||||
}
|
||||
|
||||
async findByRank(rank: string): Promise<Player[]> {
|
||||
return this.findMany({
|
||||
where: { rank }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const playerRepo = new PlayerRepository(mongo)
|
||||
const topPlayers = await playerRepo.findTopPlayers(5)
|
||||
```
|
||||
|
||||
### User Repository
|
||||
|
||||
```typescript
|
||||
import { UserRepository } from '@esengine/database'
|
||||
|
||||
const userRepo = new UserRepository(mongo)
|
||||
|
||||
// Register new user
|
||||
const user = await userRepo.register({
|
||||
username: 'john',
|
||||
password: 'securePassword123',
|
||||
email: 'john@example.com'
|
||||
})
|
||||
|
||||
// Authenticate
|
||||
const authenticated = await userRepo.authenticate('john', 'securePassword123')
|
||||
if (authenticated) {
|
||||
console.log('Login successful:', authenticated.username)
|
||||
}
|
||||
|
||||
// Change password
|
||||
await userRepo.changePassword(user.id, 'securePassword123', 'newPassword456')
|
||||
|
||||
// Role management
|
||||
await userRepo.addRole(user.id, 'admin')
|
||||
await userRepo.removeRole(user.id, 'admin')
|
||||
|
||||
// Find users
|
||||
const admins = await userRepo.findByRole('admin')
|
||||
const john = await userRepo.findByUsername('john')
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
```typescript
|
||||
const result = await playerRepo.findPaginated(
|
||||
{ page: 1, pageSize: 20 },
|
||||
{
|
||||
where: { rank: 'gold' },
|
||||
sort: { score: 'desc' }
|
||||
}
|
||||
)
|
||||
|
||||
console.log(result.data) // Player[]
|
||||
console.log(result.total) // Total count
|
||||
console.log(result.totalPages) // Total pages
|
||||
console.log(result.hasNext) // Has next page
|
||||
console.log(result.hasPrev) // Has previous page
|
||||
```
|
||||
|
||||
### Soft Delete
|
||||
|
||||
```typescript
|
||||
// Enable soft delete
|
||||
const playerRepo = createRepository<Player>(mongo, 'players', true)
|
||||
|
||||
// Delete (marks as deleted)
|
||||
await playerRepo.delete(playerId)
|
||||
|
||||
// Find excludes soft-deleted by default
|
||||
const players = await playerRepo.findMany()
|
||||
|
||||
// Include soft-deleted records
|
||||
const allPlayers = await playerRepo.findMany({
|
||||
includeSoftDeleted: true
|
||||
})
|
||||
|
||||
// Restore soft-deleted record
|
||||
await playerRepo.restore(playerId)
|
||||
```
|
||||
|
||||
### Query Options
|
||||
|
||||
```typescript
|
||||
// Complex queries
|
||||
const players = await playerRepo.findMany({
|
||||
where: {
|
||||
score: { $gte: 100 },
|
||||
rank: { $in: ['gold', 'platinum'] },
|
||||
name: { $like: 'John%' }
|
||||
},
|
||||
sort: {
|
||||
score: 'desc',
|
||||
name: 'asc'
|
||||
},
|
||||
limit: 10,
|
||||
offset: 0
|
||||
})
|
||||
|
||||
// OR conditions
|
||||
const players = await playerRepo.findMany({
|
||||
where: {
|
||||
$or: [
|
||||
{ score: { $gte: 1000 } },
|
||||
{ rank: 'legendary' }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Query Operators
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `$eq` | Equal | `{ score: { $eq: 100 } }` |
|
||||
| `$ne` | Not equal | `{ status: { $ne: 'banned' } }` |
|
||||
| `$gt` | Greater than | `{ score: { $gt: 50 } }` |
|
||||
| `$gte` | Greater or equal | `{ level: { $gte: 10 } }` |
|
||||
| `$lt` | Less than | `{ age: { $lt: 18 } }` |
|
||||
| `$lte` | Less or equal | `{ price: { $lte: 100 } }` |
|
||||
| `$in` | In array | `{ rank: { $in: ['gold', 'platinum'] } }` |
|
||||
| `$nin` | Not in array | `{ status: { $nin: ['banned'] } }` |
|
||||
| `$like` | Pattern match | `{ name: { $like: '%john%' } }` |
|
||||
| `$regex` | Regex match | `{ email: { $regex: '@gmail.com$' } }` |
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Repository API](/en/modules/database/repository/) - Repository detailed API
|
||||
- [User Management](/en/modules/database/user/) - UserRepository usage
|
||||
- [Query Syntax](/en/modules/database/query/) - Query condition syntax
|
||||
185
docs/src/content/docs/en/modules/database/query.md
Normal file
185
docs/src/content/docs/en/modules/database/query.md
Normal file
@@ -0,0 +1,185 @@
|
||||
---
|
||||
title: "Query Syntax"
|
||||
description: "Query condition operators and syntax"
|
||||
---
|
||||
|
||||
## Basic Queries
|
||||
|
||||
### Exact Match
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
name: 'John',
|
||||
status: 'active'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Using Operators
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
score: { $gte: 100 },
|
||||
rank: { $in: ['gold', 'platinum'] }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Query Operators
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `$eq` | Equal | `{ score: { $eq: 100 } }` |
|
||||
| `$ne` | Not equal | `{ status: { $ne: 'banned' } }` |
|
||||
| `$gt` | Greater than | `{ score: { $gt: 50 } }` |
|
||||
| `$gte` | Greater than or equal | `{ level: { $gte: 10 } }` |
|
||||
| `$lt` | Less than | `{ age: { $lt: 18 } }` |
|
||||
| `$lte` | Less than or equal | `{ price: { $lte: 100 } }` |
|
||||
| `$in` | In array | `{ rank: { $in: ['gold', 'platinum'] } }` |
|
||||
| `$nin` | Not in array | `{ status: { $nin: ['banned', 'suspended'] } }` |
|
||||
| `$like` | Pattern match | `{ name: { $like: '%john%' } }` |
|
||||
| `$regex` | Regex match | `{ email: { $regex: '@gmail.com$' } }` |
|
||||
|
||||
## Logical Operators
|
||||
|
||||
### $or
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
$or: [
|
||||
{ score: { $gte: 1000 } },
|
||||
{ rank: 'legendary' }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### $and
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
$and: [
|
||||
{ score: { $gte: 100 } },
|
||||
{ score: { $lte: 500 } }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Combined Usage
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
$or: [
|
||||
{ rank: 'gold' },
|
||||
{ score: { $gte: 1000 } }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Pattern Matching
|
||||
|
||||
### $like Syntax
|
||||
|
||||
- `%` - Matches any sequence of characters
|
||||
- `_` - Matches single character
|
||||
|
||||
```typescript
|
||||
// Starts with 'John'
|
||||
{ name: { $like: 'John%' } }
|
||||
|
||||
// Ends with 'son'
|
||||
{ name: { $like: '%son' } }
|
||||
|
||||
// Contains 'oh'
|
||||
{ name: { $like: '%oh%' } }
|
||||
|
||||
// Second character is 'o'
|
||||
{ name: { $like: '_o%' } }
|
||||
```
|
||||
|
||||
### $regex Syntax
|
||||
|
||||
Uses standard regular expressions:
|
||||
|
||||
```typescript
|
||||
// Starts with 'John' (case insensitive)
|
||||
{ name: { $regex: '^john' } }
|
||||
|
||||
// Gmail email
|
||||
{ email: { $regex: '@gmail\\.com$' } }
|
||||
|
||||
// Contains numbers
|
||||
{ username: { $regex: '\\d+' } }
|
||||
```
|
||||
|
||||
## Sorting
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
sort: {
|
||||
score: 'desc', // Descending
|
||||
name: 'asc' // Ascending
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
### Using limit/offset
|
||||
|
||||
```typescript
|
||||
// First page
|
||||
await repo.findMany({
|
||||
limit: 20,
|
||||
offset: 0
|
||||
})
|
||||
|
||||
// Second page
|
||||
await repo.findMany({
|
||||
limit: 20,
|
||||
offset: 20
|
||||
})
|
||||
```
|
||||
|
||||
### Using findPaginated
|
||||
|
||||
```typescript
|
||||
const result = await repo.findPaginated(
|
||||
{ page: 2, pageSize: 20 },
|
||||
{ sort: { createdAt: 'desc' } }
|
||||
)
|
||||
```
|
||||
|
||||
## Complete Examples
|
||||
|
||||
```typescript
|
||||
// Find active gold players with scores between 100-1000
|
||||
// Sort by score descending, get top 10
|
||||
const players = await repo.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
rank: 'gold',
|
||||
score: { $gte: 100, $lte: 1000 }
|
||||
},
|
||||
sort: { score: 'desc' },
|
||||
limit: 10
|
||||
})
|
||||
|
||||
// Search for users with 'john' in username or gmail email
|
||||
const users = await repo.findMany({
|
||||
where: {
|
||||
$or: [
|
||||
{ username: { $like: '%john%' } },
|
||||
{ email: { $regex: '@gmail\\.com$' } }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
244
docs/src/content/docs/en/modules/database/repository.md
Normal file
244
docs/src/content/docs/en/modules/database/repository.md
Normal file
@@ -0,0 +1,244 @@
|
||||
---
|
||||
title: "Repository API"
|
||||
description: "Generic repository interface, CRUD operations, pagination, soft delete"
|
||||
---
|
||||
|
||||
## Creating a Repository
|
||||
|
||||
### Using Factory Function
|
||||
|
||||
```typescript
|
||||
import { createRepository } from '@esengine/database'
|
||||
|
||||
const playerRepo = createRepository<Player>(mongo, 'players')
|
||||
|
||||
// Enable soft delete
|
||||
const playerRepo = createRepository<Player>(mongo, 'players', true)
|
||||
```
|
||||
|
||||
### Extending Repository
|
||||
|
||||
```typescript
|
||||
import { Repository, BaseEntity } from '@esengine/database'
|
||||
|
||||
interface Player extends BaseEntity {
|
||||
name: string
|
||||
score: number
|
||||
}
|
||||
|
||||
class PlayerRepository extends Repository<Player> {
|
||||
constructor(connection: IMongoConnection) {
|
||||
super(connection, 'players', false) // Third param: enable soft delete
|
||||
}
|
||||
|
||||
// Add custom methods
|
||||
async findTopPlayers(limit: number): Promise<Player[]> {
|
||||
return this.findMany({
|
||||
sort: { score: 'desc' },
|
||||
limit
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## BaseEntity Interface
|
||||
|
||||
All entities must extend `BaseEntity`:
|
||||
|
||||
```typescript
|
||||
interface BaseEntity {
|
||||
id: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
deletedAt?: Date // Used for soft delete
|
||||
}
|
||||
```
|
||||
|
||||
## Query Methods
|
||||
|
||||
### findById
|
||||
|
||||
```typescript
|
||||
const player = await repo.findById('player-123')
|
||||
```
|
||||
|
||||
### findOne
|
||||
|
||||
```typescript
|
||||
const player = await repo.findOne({
|
||||
where: { name: 'John' }
|
||||
})
|
||||
|
||||
const topPlayer = await repo.findOne({
|
||||
sort: { score: 'desc' }
|
||||
})
|
||||
```
|
||||
|
||||
### findMany
|
||||
|
||||
```typescript
|
||||
// Simple query
|
||||
const players = await repo.findMany({
|
||||
where: { rank: 'gold' }
|
||||
})
|
||||
|
||||
// Complex query
|
||||
const players = await repo.findMany({
|
||||
where: {
|
||||
score: { $gte: 100 },
|
||||
rank: { $in: ['gold', 'platinum'] }
|
||||
},
|
||||
sort: { score: 'desc', name: 'asc' },
|
||||
limit: 10,
|
||||
offset: 0
|
||||
})
|
||||
```
|
||||
|
||||
### findPaginated
|
||||
|
||||
```typescript
|
||||
const result = await repo.findPaginated(
|
||||
{ page: 1, pageSize: 20 },
|
||||
{
|
||||
where: { rank: 'gold' },
|
||||
sort: { score: 'desc' }
|
||||
}
|
||||
)
|
||||
|
||||
console.log(result.data) // Player[]
|
||||
console.log(result.total) // Total count
|
||||
console.log(result.totalPages) // Total pages
|
||||
console.log(result.hasNext) // Has next page
|
||||
console.log(result.hasPrev) // Has previous page
|
||||
```
|
||||
|
||||
### count
|
||||
|
||||
```typescript
|
||||
const count = await repo.count({
|
||||
where: { rank: 'gold' }
|
||||
})
|
||||
```
|
||||
|
||||
### exists
|
||||
|
||||
```typescript
|
||||
const exists = await repo.exists({
|
||||
where: { email: 'john@example.com' }
|
||||
})
|
||||
```
|
||||
|
||||
## Create Methods
|
||||
|
||||
### create
|
||||
|
||||
```typescript
|
||||
const player = await repo.create({
|
||||
name: 'John',
|
||||
score: 0
|
||||
})
|
||||
// Automatically generates id, createdAt, updatedAt
|
||||
```
|
||||
|
||||
### createMany
|
||||
|
||||
```typescript
|
||||
const players = await repo.createMany([
|
||||
{ name: 'Alice', score: 100 },
|
||||
{ name: 'Bob', score: 200 },
|
||||
{ name: 'Carol', score: 150 }
|
||||
])
|
||||
```
|
||||
|
||||
## Update Methods
|
||||
|
||||
### update
|
||||
|
||||
```typescript
|
||||
const updated = await repo.update('player-123', {
|
||||
score: 200,
|
||||
rank: 'gold'
|
||||
})
|
||||
// Automatically updates updatedAt
|
||||
```
|
||||
|
||||
## Delete Methods
|
||||
|
||||
### delete
|
||||
|
||||
```typescript
|
||||
// Hard delete
|
||||
await repo.delete('player-123')
|
||||
|
||||
// Soft delete (if enabled)
|
||||
// Actually sets the deletedAt field
|
||||
```
|
||||
|
||||
### deleteMany
|
||||
|
||||
```typescript
|
||||
const count = await repo.deleteMany({
|
||||
where: { score: { $lt: 10 } }
|
||||
})
|
||||
```
|
||||
|
||||
## Soft Delete
|
||||
|
||||
### Enabling Soft Delete
|
||||
|
||||
```typescript
|
||||
const repo = createRepository<Player>(mongo, 'players', true)
|
||||
```
|
||||
|
||||
### Query Behavior
|
||||
|
||||
```typescript
|
||||
// Excludes soft-deleted records by default
|
||||
const players = await repo.findMany()
|
||||
|
||||
// Include soft-deleted records
|
||||
const allPlayers = await repo.findMany({
|
||||
includeSoftDeleted: true
|
||||
})
|
||||
```
|
||||
|
||||
### Restore Records
|
||||
|
||||
```typescript
|
||||
await repo.restore('player-123')
|
||||
```
|
||||
|
||||
## QueryOptions
|
||||
|
||||
```typescript
|
||||
interface QueryOptions<T> {
|
||||
/** Query conditions */
|
||||
where?: WhereCondition<T>
|
||||
|
||||
/** Sorting */
|
||||
sort?: Partial<Record<keyof T, 'asc' | 'desc'>>
|
||||
|
||||
/** Limit count */
|
||||
limit?: number
|
||||
|
||||
/** Offset */
|
||||
offset?: number
|
||||
|
||||
/** Include soft-deleted records (only when soft delete is enabled) */
|
||||
includeSoftDeleted?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
## PaginatedResult
|
||||
|
||||
```typescript
|
||||
interface PaginatedResult<T> {
|
||||
data: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}
|
||||
```
|
||||
277
docs/src/content/docs/en/modules/database/user.md
Normal file
277
docs/src/content/docs/en/modules/database/user.md
Normal file
@@ -0,0 +1,277 @@
|
||||
---
|
||||
title: "User Management"
|
||||
description: "UserRepository for user registration, authentication, and role management"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
`UserRepository` provides out-of-the-box user management features:
|
||||
|
||||
- User registration and authentication
|
||||
- Password hashing (using scrypt)
|
||||
- Role management
|
||||
- Account status management
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
import { UserRepository } from '@esengine/database'
|
||||
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
})
|
||||
await mongo.connect()
|
||||
|
||||
const userRepo = new UserRepository(mongo)
|
||||
```
|
||||
|
||||
## User Registration
|
||||
|
||||
```typescript
|
||||
const user = await userRepo.register({
|
||||
username: 'john',
|
||||
password: 'securePassword123',
|
||||
email: 'john@example.com', // Optional
|
||||
displayName: 'John Doe', // Optional
|
||||
roles: ['player'] // Optional, defaults to []
|
||||
})
|
||||
|
||||
console.log(user)
|
||||
// {
|
||||
// id: 'uuid-...',
|
||||
// username: 'john',
|
||||
// email: 'john@example.com',
|
||||
// displayName: 'John Doe',
|
||||
// roles: ['player'],
|
||||
// status: 'active',
|
||||
// createdAt: Date,
|
||||
// updatedAt: Date
|
||||
// }
|
||||
```
|
||||
|
||||
**Note**: `register` returns a `SafeUser` which excludes the password hash.
|
||||
|
||||
## User Authentication
|
||||
|
||||
```typescript
|
||||
const user = await userRepo.authenticate('john', 'securePassword123')
|
||||
|
||||
if (user) {
|
||||
console.log('Login successful:', user.username)
|
||||
} else {
|
||||
console.log('Invalid username or password')
|
||||
}
|
||||
```
|
||||
|
||||
## Password Management
|
||||
|
||||
### Change Password
|
||||
|
||||
```typescript
|
||||
const success = await userRepo.changePassword(
|
||||
userId,
|
||||
'oldPassword123',
|
||||
'newPassword456'
|
||||
)
|
||||
|
||||
if (success) {
|
||||
console.log('Password changed successfully')
|
||||
} else {
|
||||
console.log('Invalid current password')
|
||||
}
|
||||
```
|
||||
|
||||
### Reset Password
|
||||
|
||||
```typescript
|
||||
// Admin directly resets password
|
||||
const success = await userRepo.resetPassword(userId, 'newPassword123')
|
||||
```
|
||||
|
||||
## Role Management
|
||||
|
||||
### Add Role
|
||||
|
||||
```typescript
|
||||
await userRepo.addRole(userId, 'admin')
|
||||
await userRepo.addRole(userId, 'moderator')
|
||||
```
|
||||
|
||||
### Remove Role
|
||||
|
||||
```typescript
|
||||
await userRepo.removeRole(userId, 'moderator')
|
||||
```
|
||||
|
||||
### Query Roles
|
||||
|
||||
```typescript
|
||||
// Find all admins
|
||||
const admins = await userRepo.findByRole('admin')
|
||||
|
||||
// Check if user has a role
|
||||
const user = await userRepo.findById(userId)
|
||||
const isAdmin = user?.roles.includes('admin')
|
||||
```
|
||||
|
||||
## Querying Users
|
||||
|
||||
### Find by Username
|
||||
|
||||
```typescript
|
||||
const user = await userRepo.findByUsername('john')
|
||||
```
|
||||
|
||||
### Find by Email
|
||||
|
||||
```typescript
|
||||
const user = await userRepo.findByEmail('john@example.com')
|
||||
```
|
||||
|
||||
### Find by Role
|
||||
|
||||
```typescript
|
||||
const admins = await userRepo.findByRole('admin')
|
||||
```
|
||||
|
||||
### Using Inherited Methods
|
||||
|
||||
```typescript
|
||||
// Paginated query
|
||||
const result = await userRepo.findPaginated(
|
||||
{ page: 1, pageSize: 20 },
|
||||
{
|
||||
where: { status: 'active' },
|
||||
sort: { createdAt: 'desc' }
|
||||
}
|
||||
)
|
||||
|
||||
// Complex query
|
||||
const users = await userRepo.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
roles: { $in: ['admin', 'moderator'] }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Account Status
|
||||
|
||||
```typescript
|
||||
type UserStatus = 'active' | 'inactive' | 'banned' | 'suspended'
|
||||
```
|
||||
|
||||
### Update Status
|
||||
|
||||
```typescript
|
||||
await userRepo.update(userId, { status: 'banned' })
|
||||
```
|
||||
|
||||
### Query by Status
|
||||
|
||||
```typescript
|
||||
const activeUsers = await userRepo.findMany({
|
||||
where: { status: 'active' }
|
||||
})
|
||||
|
||||
const bannedUsers = await userRepo.findMany({
|
||||
where: { status: 'banned' }
|
||||
})
|
||||
```
|
||||
|
||||
## Type Definitions
|
||||
|
||||
### UserEntity
|
||||
|
||||
```typescript
|
||||
interface UserEntity extends BaseEntity {
|
||||
username: string
|
||||
passwordHash: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
roles: string[]
|
||||
status: UserStatus
|
||||
lastLoginAt?: Date
|
||||
}
|
||||
```
|
||||
|
||||
### SafeUser
|
||||
|
||||
```typescript
|
||||
type SafeUser = Omit<UserEntity, 'passwordHash'>
|
||||
```
|
||||
|
||||
### CreateUserParams
|
||||
|
||||
```typescript
|
||||
interface CreateUserParams {
|
||||
username: string
|
||||
password: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
roles?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
## Password Utilities
|
||||
|
||||
Standalone password utility functions:
|
||||
|
||||
```typescript
|
||||
import { hashPassword, verifyPassword } from '@esengine/database'
|
||||
|
||||
// Hash password
|
||||
const hash = await hashPassword('myPassword123')
|
||||
|
||||
// Verify password
|
||||
const isValid = await verifyPassword('myPassword123', hash)
|
||||
```
|
||||
|
||||
### Security Notes
|
||||
|
||||
- Uses Node.js built-in `scrypt` algorithm
|
||||
- Automatically generates random salt
|
||||
- Uses secure iteration parameters by default
|
||||
- Hash format: `salt:hash` (both hex encoded)
|
||||
|
||||
## Extending UserRepository
|
||||
|
||||
```typescript
|
||||
import { UserRepository, UserEntity } from '@esengine/database'
|
||||
|
||||
interface GameUser extends UserEntity {
|
||||
level: number
|
||||
experience: number
|
||||
coins: number
|
||||
}
|
||||
|
||||
class GameUserRepository extends UserRepository {
|
||||
// Override collection name
|
||||
constructor(connection: IMongoConnection) {
|
||||
super(connection, 'game_users')
|
||||
}
|
||||
|
||||
// Add game-related methods
|
||||
async addExperience(userId: string, amount: number): Promise<GameUser | null> {
|
||||
const user = await this.findById(userId) as GameUser | null
|
||||
if (!user) return null
|
||||
|
||||
const newExp = user.experience + amount
|
||||
const newLevel = Math.floor(newExp / 1000) + 1
|
||||
|
||||
return this.update(userId, {
|
||||
experience: newExp,
|
||||
level: newLevel
|
||||
}) as Promise<GameUser | null>
|
||||
}
|
||||
|
||||
async findTopPlayers(limit: number = 10): Promise<GameUser[]> {
|
||||
return this.findMany({
|
||||
sort: { level: 'desc', experience: 'desc' },
|
||||
limit
|
||||
}) as Promise<GameUser[]>
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -36,6 +36,13 @@ ESEngine provides a rich set of modules that can be imported as needed.
|
||||
| [Network](/en/modules/network/) | `@esengine/network` | Multiplayer game networking |
|
||||
| [Transaction](/en/modules/transaction/) | `@esengine/transaction` | Game transactions with distributed support |
|
||||
|
||||
### Database
|
||||
|
||||
| Module | Package | Description |
|
||||
|--------|---------|-------------|
|
||||
| [Database Drivers](/en/modules/database-drivers/) | `@esengine/database-drivers` | MongoDB, Redis connection management |
|
||||
| [Database Repository](/en/modules/database/) | `@esengine/database` | Repository pattern data operations |
|
||||
|
||||
## Installation
|
||||
|
||||
All modules can be installed independently:
|
||||
|
||||
@@ -92,6 +92,355 @@ const token = jwtProvider.sign({
|
||||
const payload = jwtProvider.decode(token)
|
||||
```
|
||||
|
||||
### Custom Provider
|
||||
|
||||
You can create custom authentication providers by implementing the `IAuthProvider` interface to integrate with any authentication system (OAuth, LDAP, custom database auth, etc.).
|
||||
|
||||
#### IAuthProvider Interface
|
||||
|
||||
```typescript
|
||||
interface IAuthProvider<TUser = unknown, TCredentials = unknown> {
|
||||
/** Provider name */
|
||||
readonly name: string;
|
||||
|
||||
/** Verify credentials */
|
||||
verify(credentials: TCredentials): Promise<AuthResult<TUser>>;
|
||||
|
||||
/** Refresh token (optional) */
|
||||
refresh?(token: string): Promise<AuthResult<TUser>>;
|
||||
|
||||
/** Revoke token (optional) */
|
||||
revoke?(token: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
interface AuthResult<TUser> {
|
||||
success: boolean;
|
||||
user?: TUser;
|
||||
error?: string;
|
||||
errorCode?: AuthErrorCode;
|
||||
token?: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
type AuthErrorCode =
|
||||
| 'INVALID_CREDENTIALS'
|
||||
| 'EXPIRED_TOKEN'
|
||||
| 'INVALID_TOKEN'
|
||||
| 'USER_NOT_FOUND'
|
||||
| 'ACCOUNT_DISABLED'
|
||||
| 'RATE_LIMITED'
|
||||
| 'INSUFFICIENT_PERMISSIONS';
|
||||
```
|
||||
|
||||
#### Custom Provider Examples
|
||||
|
||||
**Example 1: Database Password Authentication**
|
||||
|
||||
```typescript
|
||||
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
username: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
interface PasswordCredentials {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
class DatabaseAuthProvider implements IAuthProvider<User, PasswordCredentials> {
|
||||
readonly name = 'database'
|
||||
|
||||
async verify(credentials: PasswordCredentials): Promise<AuthResult<User>> {
|
||||
const { username, password } = credentials
|
||||
|
||||
// Query user from database
|
||||
const user = await db.users.findByUsername(username)
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'User not found',
|
||||
errorCode: 'USER_NOT_FOUND'
|
||||
}
|
||||
}
|
||||
|
||||
// Verify password (using bcrypt or similar)
|
||||
const isValid = await bcrypt.compare(password, user.passwordHash)
|
||||
if (!isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid password',
|
||||
errorCode: 'INVALID_CREDENTIALS'
|
||||
}
|
||||
}
|
||||
|
||||
// Check account status
|
||||
if (user.disabled) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Account is disabled',
|
||||
errorCode: 'ACCOUNT_DISABLED'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
roles: user.roles
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example 2: OAuth/Third-party Authentication**
|
||||
|
||||
```typescript
|
||||
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
|
||||
|
||||
interface OAuthUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
provider: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
interface OAuthCredentials {
|
||||
provider: 'google' | 'github' | 'discord'
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
class OAuthProvider implements IAuthProvider<OAuthUser, OAuthCredentials> {
|
||||
readonly name = 'oauth'
|
||||
|
||||
async verify(credentials: OAuthCredentials): Promise<AuthResult<OAuthUser>> {
|
||||
const { provider, accessToken } = credentials
|
||||
|
||||
try {
|
||||
// Verify token with provider
|
||||
const profile = await this.fetchUserProfile(provider, accessToken)
|
||||
|
||||
// Find or create local user
|
||||
let user = await db.users.findByOAuth(provider, profile.id)
|
||||
if (!user) {
|
||||
user = await db.users.create({
|
||||
oauthProvider: provider,
|
||||
oauthId: profile.id,
|
||||
email: profile.email,
|
||||
name: profile.name,
|
||||
roles: ['player']
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
provider,
|
||||
roles: user.roles
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'OAuth verification failed',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchUserProfile(provider: string, token: string) {
|
||||
switch (provider) {
|
||||
case 'google':
|
||||
return fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).then(r => r.json())
|
||||
case 'github':
|
||||
return fetch('https://api.github.com/user', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).then(r => r.json())
|
||||
// Other providers...
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${provider}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example 3: API Key Authentication**
|
||||
|
||||
```typescript
|
||||
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
|
||||
|
||||
interface ApiUser {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
rateLimit: number
|
||||
}
|
||||
|
||||
class ApiKeyAuthProvider implements IAuthProvider<ApiUser, string> {
|
||||
readonly name = 'api-key'
|
||||
|
||||
private revokedKeys = new Set<string>()
|
||||
|
||||
async verify(apiKey: string): Promise<AuthResult<ApiUser>> {
|
||||
if (!apiKey || !apiKey.startsWith('sk_')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid API Key format',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
}
|
||||
}
|
||||
|
||||
if (this.revokedKeys.has(apiKey)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API Key has been revoked',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
}
|
||||
}
|
||||
|
||||
// Query API Key from database
|
||||
const keyData = await db.apiKeys.findByKey(apiKey)
|
||||
if (!keyData) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API Key not found',
|
||||
errorCode: 'INVALID_CREDENTIALS'
|
||||
}
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (keyData.expiresAt && keyData.expiresAt < Date.now()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API Key has expired',
|
||||
errorCode: 'EXPIRED_TOKEN'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: keyData.userId,
|
||||
name: keyData.name,
|
||||
roles: keyData.roles,
|
||||
rateLimit: keyData.rateLimit
|
||||
},
|
||||
expiresAt: keyData.expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
async revoke(apiKey: string): Promise<boolean> {
|
||||
this.revokedKeys.add(apiKey)
|
||||
await db.apiKeys.revoke(apiKey)
|
||||
return true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Using Custom Providers
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server'
|
||||
import { withAuth } from '@esengine/server/auth'
|
||||
|
||||
// Create custom provider
|
||||
const dbAuthProvider = new DatabaseAuthProvider()
|
||||
|
||||
// Or use OAuth provider
|
||||
const oauthProvider = new OAuthProvider()
|
||||
|
||||
// Use custom provider
|
||||
const server = withAuth(await createServer({ port: 3000 }), {
|
||||
provider: dbAuthProvider, // or oauthProvider
|
||||
|
||||
// Extract credentials from WebSocket connection request
|
||||
extractCredentials: (req) => {
|
||||
const url = new URL(req.url, 'http://localhost')
|
||||
|
||||
// For database auth: get from query params
|
||||
const username = url.searchParams.get('username')
|
||||
const password = url.searchParams.get('password')
|
||||
if (username && password) {
|
||||
return { username, password }
|
||||
}
|
||||
|
||||
// For OAuth: get from token param
|
||||
const provider = url.searchParams.get('provider')
|
||||
const accessToken = url.searchParams.get('access_token')
|
||||
if (provider && accessToken) {
|
||||
return { provider, accessToken }
|
||||
}
|
||||
|
||||
// For API Key: get from header
|
||||
const apiKey = req.headers['x-api-key']
|
||||
if (apiKey) {
|
||||
return apiKey as string
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
onAuthFailure: (conn, error) => {
|
||||
console.log(`Auth failed: ${error.errorCode} - ${error.error}`)
|
||||
}
|
||||
})
|
||||
|
||||
await server.start()
|
||||
```
|
||||
|
||||
#### Combining Multiple Providers
|
||||
|
||||
You can create a composite provider to support multiple authentication methods:
|
||||
|
||||
```typescript
|
||||
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
|
||||
|
||||
interface MultiAuthCredentials {
|
||||
type: 'jwt' | 'oauth' | 'apikey' | 'password'
|
||||
data: unknown
|
||||
}
|
||||
|
||||
class MultiAuthProvider implements IAuthProvider<User, MultiAuthCredentials> {
|
||||
readonly name = 'multi'
|
||||
|
||||
constructor(
|
||||
private jwtProvider: JwtAuthProvider<User>,
|
||||
private oauthProvider: OAuthProvider,
|
||||
private apiKeyProvider: ApiKeyAuthProvider,
|
||||
private dbProvider: DatabaseAuthProvider
|
||||
) {}
|
||||
|
||||
async verify(credentials: MultiAuthCredentials): Promise<AuthResult<User>> {
|
||||
switch (credentials.type) {
|
||||
case 'jwt':
|
||||
return this.jwtProvider.verify(credentials.data as string)
|
||||
case 'oauth':
|
||||
return this.oauthProvider.verify(credentials.data as OAuthCredentials)
|
||||
case 'apikey':
|
||||
return this.apiKeyProvider.verify(credentials.data as string)
|
||||
case 'password':
|
||||
return this.dbProvider.verify(credentials.data as PasswordCredentials)
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unsupported authentication type',
|
||||
errorCode: 'INVALID_CREDENTIALS'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Session Provider
|
||||
|
||||
Use server-side sessions for stateful authentication:
|
||||
|
||||
441
docs/src/content/docs/en/modules/network/distributed.md
Normal file
441
docs/src/content/docs/en/modules/network/distributed.md
Normal file
@@ -0,0 +1,441 @@
|
||||
---
|
||||
title: "Distributed Rooms"
|
||||
description: "Multi-server room management with DistributedRoomManager"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Distributed room support allows multiple server instances to share a room registry, enabling cross-server player routing and failover.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Server A Server B Server C │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Room 1 │ │ Room 3 │ │ Room 5 │ │
|
||||
│ │ Room 2 │ │ Room 4 │ │ Room 6 │ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────────┼─────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ IDistributedAdapter │ │
|
||||
│ │ (Redis / Memory) │ │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Single Server Mode (Testing)
|
||||
|
||||
```typescript
|
||||
import {
|
||||
DistributedRoomManager,
|
||||
MemoryAdapter,
|
||||
Room
|
||||
} from '@esengine/server';
|
||||
|
||||
// Define room type
|
||||
class GameRoom extends Room {
|
||||
maxPlayers = 4;
|
||||
}
|
||||
|
||||
// Create adapter and manager
|
||||
const adapter = new MemoryAdapter();
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'localhost',
|
||||
serverPort: 3000
|
||||
}, (conn, type, data) => conn.send(JSON.stringify({ type, data })));
|
||||
|
||||
// Register room type
|
||||
manager.define('game', GameRoom);
|
||||
|
||||
// Start manager
|
||||
await manager.start();
|
||||
|
||||
// Distributed join/create room
|
||||
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
|
||||
if ('redirect' in result) {
|
||||
// Player should connect to another server
|
||||
console.log(`Redirect to: ${result.redirect}`);
|
||||
} else {
|
||||
// Player joined local room
|
||||
const { room, player } = result;
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
await manager.stop(true);
|
||||
```
|
||||
|
||||
### Multi-Server Mode (Production)
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
import { DistributedRoomManager, RedisAdapter } from '@esengine/server';
|
||||
|
||||
const adapter = new RedisAdapter({
|
||||
factory: () => new Redis({
|
||||
host: 'redis.example.com',
|
||||
port: 6379
|
||||
}),
|
||||
prefix: 'game:',
|
||||
serverTtl: 30,
|
||||
snapshotTtl: 86400
|
||||
});
|
||||
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: process.env.SERVER_ID,
|
||||
serverAddress: process.env.PUBLIC_IP,
|
||||
serverPort: 3000,
|
||||
heartbeatInterval: 5000,
|
||||
snapshotInterval: 30000,
|
||||
enableFailover: true,
|
||||
capacity: 100
|
||||
}, sendFn);
|
||||
```
|
||||
|
||||
## DistributedRoomManager
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `serverId` | `string` | required | Unique server identifier |
|
||||
| `serverAddress` | `string` | required | Public address for client connections |
|
||||
| `serverPort` | `number` | required | Server port |
|
||||
| `heartbeatInterval` | `number` | `5000` | Heartbeat interval (ms) |
|
||||
| `snapshotInterval` | `number` | `30000` | State snapshot interval, 0 to disable |
|
||||
| `migrationTimeout` | `number` | `10000` | Room migration timeout |
|
||||
| `enableFailover` | `boolean` | `true` | Enable automatic failover |
|
||||
| `capacity` | `number` | `100` | Max rooms on this server |
|
||||
|
||||
### Lifecycle Methods
|
||||
|
||||
#### start()
|
||||
|
||||
Start the distributed room manager. Connects to adapter, registers server, starts heartbeat.
|
||||
|
||||
```typescript
|
||||
await manager.start();
|
||||
```
|
||||
|
||||
#### stop(graceful?)
|
||||
|
||||
Stop the manager. If `graceful=true`, marks server as draining and saves all room snapshots.
|
||||
|
||||
```typescript
|
||||
await manager.stop(true);
|
||||
```
|
||||
|
||||
### Routing Methods
|
||||
|
||||
#### joinOrCreateDistributed()
|
||||
|
||||
Join or create a room with distributed awareness. Returns `{ room, player }` for local rooms or `{ redirect: string }` for remote rooms.
|
||||
|
||||
```typescript
|
||||
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
|
||||
|
||||
if ('redirect' in result) {
|
||||
// Client should redirect to another server
|
||||
res.json({ redirect: result.redirect });
|
||||
} else {
|
||||
// Player joined local room
|
||||
const { room, player } = result;
|
||||
}
|
||||
```
|
||||
|
||||
#### route()
|
||||
|
||||
Route a player to the appropriate room/server.
|
||||
|
||||
```typescript
|
||||
const result = await manager.route({
|
||||
roomType: 'game',
|
||||
playerId: 'p1'
|
||||
});
|
||||
|
||||
switch (result.type) {
|
||||
case 'local': // Room is on this server
|
||||
break;
|
||||
case 'redirect': // Room is on another server
|
||||
// result.serverAddress contains target server
|
||||
break;
|
||||
case 'create': // No room exists, need to create
|
||||
break;
|
||||
case 'unavailable': // Cannot find or create room
|
||||
// result.reason contains error message
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
#### saveSnapshot()
|
||||
|
||||
Manually save a room's state snapshot.
|
||||
|
||||
```typescript
|
||||
await manager.saveSnapshot(roomId);
|
||||
```
|
||||
|
||||
#### restoreFromSnapshot()
|
||||
|
||||
Restore a room from its saved snapshot.
|
||||
|
||||
```typescript
|
||||
const success = await manager.restoreFromSnapshot(roomId);
|
||||
```
|
||||
|
||||
### Query Methods
|
||||
|
||||
#### getServers()
|
||||
|
||||
Get all online servers.
|
||||
|
||||
```typescript
|
||||
const servers = await manager.getServers();
|
||||
```
|
||||
|
||||
#### queryDistributedRooms()
|
||||
|
||||
Query rooms across all servers.
|
||||
|
||||
```typescript
|
||||
const rooms = await manager.queryDistributedRooms({
|
||||
roomType: 'game',
|
||||
hasSpace: true,
|
||||
notLocked: true
|
||||
});
|
||||
```
|
||||
|
||||
## IDistributedAdapter
|
||||
|
||||
Interface for distributed backends. Implement this to add support for Redis, message queues, etc.
|
||||
|
||||
### Built-in Adapters
|
||||
|
||||
#### MemoryAdapter
|
||||
|
||||
In-memory implementation for testing and single-server mode.
|
||||
|
||||
```typescript
|
||||
const adapter = new MemoryAdapter({
|
||||
serverTtl: 15000, // Server offline after no heartbeat (ms)
|
||||
enableTtlCheck: true, // Enable automatic TTL checking
|
||||
ttlCheckInterval: 5000 // TTL check interval (ms)
|
||||
});
|
||||
```
|
||||
|
||||
#### RedisAdapter
|
||||
|
||||
Redis-based implementation for production multi-server deployments.
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
import { RedisAdapter } from '@esengine/server';
|
||||
|
||||
const adapter = new RedisAdapter({
|
||||
factory: () => new Redis('redis://localhost:6379'),
|
||||
prefix: 'game:', // Key prefix (default: 'dist:')
|
||||
serverTtl: 30, // Server TTL in seconds (default: 30)
|
||||
roomTtl: 0, // Room TTL, 0 = never expire (default: 0)
|
||||
snapshotTtl: 86400, // Snapshot TTL in seconds (default: 24h)
|
||||
channel: 'game:events' // Pub/Sub channel (default: 'distributed:events')
|
||||
});
|
||||
```
|
||||
|
||||
**RedisAdapter Configuration:**
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `factory` | `() => RedisClient` | required | Redis client factory (lazy connection) |
|
||||
| `prefix` | `string` | `'dist:'` | Key prefix for all Redis keys |
|
||||
| `serverTtl` | `number` | `30` | Server TTL in seconds |
|
||||
| `roomTtl` | `number` | `0` | Room TTL in seconds, 0 = no expiry |
|
||||
| `snapshotTtl` | `number` | `86400` | Snapshot TTL in seconds |
|
||||
| `channel` | `string` | `'distributed:events'` | Pub/Sub channel name |
|
||||
|
||||
**Features:**
|
||||
- Server registry with automatic heartbeat TTL
|
||||
- Room registry with cross-server lookup
|
||||
- State snapshots with configurable TTL
|
||||
- Pub/Sub for cross-server events
|
||||
- Distributed locks using Redis SET NX
|
||||
|
||||
### Custom Adapters
|
||||
|
||||
```typescript
|
||||
import type { IDistributedAdapter } from '@esengine/server';
|
||||
|
||||
class MyAdapter implements IDistributedAdapter {
|
||||
// Lifecycle
|
||||
async connect(): Promise<void> { }
|
||||
async disconnect(): Promise<void> { }
|
||||
isConnected(): boolean { return true; }
|
||||
|
||||
// Server Registry
|
||||
async registerServer(server: ServerRegistration): Promise<void> { }
|
||||
async unregisterServer(serverId: string): Promise<void> { }
|
||||
async heartbeat(serverId: string): Promise<void> { }
|
||||
async getServers(): Promise<ServerRegistration[]> { return []; }
|
||||
|
||||
// Room Registry
|
||||
async registerRoom(room: RoomRegistration): Promise<void> { }
|
||||
async unregisterRoom(roomId: string): Promise<void> { }
|
||||
async queryRooms(query: RoomQuery): Promise<RoomRegistration[]> { return []; }
|
||||
async findAvailableRoom(roomType: string): Promise<RoomRegistration | null> { return null; }
|
||||
|
||||
// State Snapshots
|
||||
async saveSnapshot(snapshot: RoomSnapshot): Promise<void> { }
|
||||
async loadSnapshot(roomId: string): Promise<RoomSnapshot | null> { return null; }
|
||||
|
||||
// Pub/Sub
|
||||
async publish(event: DistributedEvent): Promise<void> { }
|
||||
async subscribe(pattern: string, handler: Function): Promise<() => void> { return () => {}; }
|
||||
|
||||
// Distributed Locks
|
||||
async acquireLock(key: string, ttlMs: number): Promise<boolean> { return true; }
|
||||
async releaseLock(key: string): Promise<void> { }
|
||||
}
|
||||
```
|
||||
|
||||
## Player Routing Flow
|
||||
|
||||
```
|
||||
Client Server A Server B
|
||||
│ │ │
|
||||
│─── joinOrCreate ────────►│ │
|
||||
│ │ │
|
||||
│ │── findAvailableRoom() ───►│
|
||||
│ │◄──── room on Server B ────│
|
||||
│ │ │
|
||||
│◄─── redirect: B:3001 ────│ │
|
||||
│ │ │
|
||||
│───────────────── connect to Server B ───────────────►│
|
||||
│ │ │
|
||||
│◄─────────────────────────────── joined ─────────────│
|
||||
```
|
||||
|
||||
## Event Types
|
||||
|
||||
The distributed system publishes these events:
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `server:online` | Server came online |
|
||||
| `server:offline` | Server went offline |
|
||||
| `server:draining` | Server is draining |
|
||||
| `room:created` | Room was created |
|
||||
| `room:disposed` | Room was disposed |
|
||||
| `room:updated` | Room info updated |
|
||||
| `room:message` | Cross-server room message |
|
||||
| `room:migrated` | Room migrated to another server |
|
||||
| `player:joined` | Player joined room |
|
||||
| `player:left` | Player left room |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Unique Server IDs** - Use hostname, container ID, or UUID
|
||||
|
||||
2. **Configure Proper Heartbeat** - Balance between freshness and network overhead
|
||||
|
||||
3. **Enable Snapshots for Stateful Rooms** - Ensure room state survives server restarts
|
||||
|
||||
4. **Handle Redirects Gracefully** - Client should reconnect to target server
|
||||
```typescript
|
||||
// Client handling redirect
|
||||
if (response.redirect) {
|
||||
await client.disconnect();
|
||||
await client.connect(response.redirect);
|
||||
await client.joinRoom(roomId);
|
||||
}
|
||||
```
|
||||
|
||||
5. **Use Distributed Locks** - Prevent race conditions in joinOrCreate
|
||||
|
||||
## Using createServer Integration
|
||||
|
||||
The simplest way to use distributed rooms is through `createServer`'s `distributed` config:
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server';
|
||||
import { RedisAdapter, Room } from '@esengine/server';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
class GameRoom extends Room {
|
||||
maxPlayers = 4;
|
||||
}
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
distributed: {
|
||||
enabled: true,
|
||||
adapter: new RedisAdapter({ factory: () => new Redis() }),
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'ws://192.168.1.100',
|
||||
serverPort: 3000,
|
||||
enableFailover: true,
|
||||
capacity: 100
|
||||
}
|
||||
});
|
||||
|
||||
server.define('game', GameRoom);
|
||||
await server.start();
|
||||
```
|
||||
|
||||
When clients call the `JoinRoom` API, the server will automatically:
|
||||
1. Find available rooms (local or remote)
|
||||
2. If room is on another server, send `$redirect` message to client
|
||||
3. Client receives redirect and connects to target server
|
||||
|
||||
## Load Balancing
|
||||
|
||||
Use `LoadBalancedRouter` for server selection:
|
||||
|
||||
```typescript
|
||||
import { LoadBalancedRouter, createLoadBalancedRouter } from '@esengine/server';
|
||||
|
||||
// Using factory function
|
||||
const router = createLoadBalancedRouter('least-players');
|
||||
|
||||
// Or create directly
|
||||
const router = new LoadBalancedRouter({
|
||||
strategy: 'least-rooms', // Select server with fewest rooms
|
||||
preferLocal: true // Prefer local server
|
||||
});
|
||||
|
||||
// Available strategies
|
||||
// - 'round-robin': Round robin selection
|
||||
// - 'least-rooms': Fewest rooms
|
||||
// - 'least-players': Fewest players
|
||||
// - 'random': Random selection
|
||||
// - 'weighted': Weighted by capacity usage
|
||||
```
|
||||
|
||||
## Failover
|
||||
|
||||
When a server goes offline with `enableFailover` enabled, the system will automatically:
|
||||
|
||||
1. Detect server offline (via heartbeat timeout)
|
||||
2. Query all rooms on that server
|
||||
3. Use distributed lock to prevent multiple servers recovering same room
|
||||
4. Restore room state from snapshot
|
||||
5. Publish `room:migrated` event to notify other servers
|
||||
|
||||
```typescript
|
||||
// Ensure periodic snapshots
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'localhost',
|
||||
serverPort: 3000,
|
||||
snapshotInterval: 30000, // Save snapshot every 30 seconds
|
||||
enableFailover: true // Enable failover
|
||||
}, sendFn);
|
||||
```
|
||||
|
||||
## Future Releases
|
||||
|
||||
- Redis Cluster support
|
||||
- More load balancing strategies (geo-location, latency-aware)
|
||||
326
docs/src/content/docs/en/modules/network/fixed-point.md
Normal file
326
docs/src/content/docs/en/modules/network/fixed-point.md
Normal 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
|
||||
679
docs/src/content/docs/en/modules/network/http.md
Normal file
679
docs/src/content/docs/en/modules/network/http.md
Normal file
@@ -0,0 +1,679 @@
|
||||
---
|
||||
title: "HTTP Routing"
|
||||
description: "HTTP REST API routing with WebSocket port sharing support"
|
||||
---
|
||||
|
||||
`@esengine/server` includes a lightweight HTTP routing feature that can share the same port with WebSocket services, making it easy to implement REST APIs.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Inline Route Definition
|
||||
|
||||
The simplest way is to define HTTP routes directly when creating the server:
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server'
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
http: {
|
||||
'/api/health': (req, res) => {
|
||||
res.json({ status: 'ok', time: Date.now() })
|
||||
},
|
||||
'/api/users': {
|
||||
GET: (req, res) => {
|
||||
res.json({ users: [] })
|
||||
},
|
||||
POST: async (req, res) => {
|
||||
const body = req.body as { name: string }
|
||||
res.status(201).json({ id: '1', name: body.name })
|
||||
}
|
||||
}
|
||||
},
|
||||
cors: true // Enable CORS
|
||||
})
|
||||
|
||||
await server.start()
|
||||
```
|
||||
|
||||
### File-based Routing
|
||||
|
||||
For larger projects, file-based routing is recommended. Create a `src/http` directory where each file corresponds to a route:
|
||||
|
||||
```typescript
|
||||
// src/http/login.ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
interface LoginBody {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export default defineHttp<LoginBody>({
|
||||
method: 'POST',
|
||||
handler(req, res) {
|
||||
const { username, password } = req.body as LoginBody
|
||||
|
||||
// Validate user...
|
||||
if (username === 'admin' && password === '123456') {
|
||||
res.json({ token: 'jwt-token-here', userId: 'user-1' })
|
||||
} else {
|
||||
res.error(401, 'Invalid username or password')
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
// server.ts
|
||||
import { createServer } from '@esengine/server'
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
httpDir: './src/http', // HTTP routes directory
|
||||
httpPrefix: '/api', // Route prefix
|
||||
cors: true
|
||||
})
|
||||
|
||||
await server.start()
|
||||
// Route: POST /api/login
|
||||
```
|
||||
|
||||
## defineHttp Definition
|
||||
|
||||
`defineHttp` is used to define type-safe HTTP handlers:
|
||||
|
||||
```typescript
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
interface CreateUserBody {
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export default defineHttp<CreateUserBody>({
|
||||
// HTTP method (default POST)
|
||||
method: 'POST',
|
||||
|
||||
// Handler function
|
||||
handler(req, res) {
|
||||
const body = req.body as CreateUserBody
|
||||
// Handle request...
|
||||
res.status(201).json({ id: 'new-user-id' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Supported HTTP Methods
|
||||
|
||||
```typescript
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS'
|
||||
```
|
||||
|
||||
## HttpRequest Object
|
||||
|
||||
The HTTP request object contains the following properties:
|
||||
|
||||
```typescript
|
||||
interface HttpRequest {
|
||||
/** Raw Node.js IncomingMessage */
|
||||
raw: IncomingMessage
|
||||
|
||||
/** HTTP method */
|
||||
method: string
|
||||
|
||||
/** Request path */
|
||||
path: string
|
||||
|
||||
/** Route parameters (extracted from URL path, e.g., /users/:id) */
|
||||
params: Record<string, string>
|
||||
|
||||
/** Query parameters */
|
||||
query: Record<string, string>
|
||||
|
||||
/** Request headers */
|
||||
headers: Record<string, string | string[] | undefined>
|
||||
|
||||
/** Parsed request body */
|
||||
body: unknown
|
||||
|
||||
/** Client IP */
|
||||
ip: string
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```typescript
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
// Get query parameters
|
||||
const page = parseInt(req.query.page ?? '1')
|
||||
const limit = parseInt(req.query.limit ?? '10')
|
||||
|
||||
// Get request headers
|
||||
const authHeader = req.headers.authorization
|
||||
|
||||
// Get client IP
|
||||
console.log('Request from:', req.ip)
|
||||
|
||||
res.json({ page, limit })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Body Parsing
|
||||
|
||||
The request body is automatically parsed based on `Content-Type`:
|
||||
|
||||
- `application/json` - Parsed as JSON object
|
||||
- `application/x-www-form-urlencoded` - Parsed as key-value object
|
||||
- Others - Kept as raw string
|
||||
|
||||
```typescript
|
||||
export default defineHttp<{ name: string; age: number }>({
|
||||
method: 'POST',
|
||||
handler(req, res) {
|
||||
// body is already parsed
|
||||
const { name, age } = req.body as { name: string; age: number }
|
||||
res.json({ received: { name, age } })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## HttpResponse Object
|
||||
|
||||
The HTTP response object provides a chainable API:
|
||||
|
||||
```typescript
|
||||
interface HttpResponse {
|
||||
/** Raw Node.js ServerResponse */
|
||||
raw: ServerResponse
|
||||
|
||||
/** Set status code */
|
||||
status(code: number): HttpResponse
|
||||
|
||||
/** Set response header */
|
||||
header(name: string, value: string): HttpResponse
|
||||
|
||||
/** Send JSON response */
|
||||
json(data: unknown): void
|
||||
|
||||
/** Send text response */
|
||||
text(data: string): void
|
||||
|
||||
/** Send error response */
|
||||
error(code: number, message: string): void
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```typescript
|
||||
export default defineHttp({
|
||||
method: 'POST',
|
||||
handler(req, res) {
|
||||
// Set status code and custom headers
|
||||
res
|
||||
.status(201)
|
||||
.header('X-Custom-Header', 'value')
|
||||
.json({ created: true })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
// Send error response
|
||||
res.error(404, 'Resource not found')
|
||||
// Equivalent to: res.status(404).json({ error: 'Resource not found' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
// Send plain text
|
||||
res.text('Hello, World!')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## File Routing Conventions
|
||||
|
||||
### Name Conversion
|
||||
|
||||
File names are automatically converted to route paths:
|
||||
|
||||
| File Path | Route Path (prefix=/api) |
|
||||
|-----------|-------------------------|
|
||||
| `login.ts` | `/api/login` |
|
||||
| `users/profile.ts` | `/api/users/profile` |
|
||||
| `users/[id].ts` | `/api/users/:id` |
|
||||
| `game/room/[roomId].ts` | `/api/game/room/:roomId` |
|
||||
|
||||
### Dynamic Route Parameters
|
||||
|
||||
Use `[param]` syntax to define dynamic parameters:
|
||||
|
||||
```typescript
|
||||
// src/http/users/[id].ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
// Get route parameter directly from params
|
||||
const { id } = req.params
|
||||
res.json({ userId: id })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Multiple parameters:
|
||||
|
||||
```typescript
|
||||
// src/http/users/[userId]/posts/[postId].ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
const { userId, postId } = req.params
|
||||
res.json({ userId, postId })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Skip Rules
|
||||
|
||||
The following files are automatically skipped:
|
||||
|
||||
- Files starting with `_` (e.g., `_helper.ts`)
|
||||
- `index.ts` / `index.js` files
|
||||
- Non `.ts` / `.js` / `.mts` / `.mjs` files
|
||||
|
||||
### Directory Structure Example
|
||||
|
||||
```
|
||||
src/
|
||||
└── http/
|
||||
├── _utils.ts # Skipped (underscore prefix)
|
||||
├── index.ts # Skipped (index file)
|
||||
├── health.ts # GET /api/health
|
||||
├── login.ts # POST /api/login
|
||||
├── register.ts # POST /api/register
|
||||
└── users/
|
||||
├── index.ts # Skipped
|
||||
├── list.ts # GET /api/users/list
|
||||
└── [id].ts # GET /api/users/:id
|
||||
```
|
||||
|
||||
## CORS Configuration
|
||||
|
||||
### Quick Enable
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
cors: true // Use default configuration
|
||||
})
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
cors: {
|
||||
// Allowed origins
|
||||
origin: ['http://localhost:5173', 'https://myapp.com'],
|
||||
// Or use wildcard
|
||||
// origin: '*',
|
||||
// origin: true, // Reflect request origin
|
||||
|
||||
// Allowed HTTP methods
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
|
||||
// Allowed request headers
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||
|
||||
// Allow credentials (cookies)
|
||||
credentials: true,
|
||||
|
||||
// Preflight cache max age (seconds)
|
||||
maxAge: 86400
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### CorsOptions Type
|
||||
|
||||
```typescript
|
||||
interface CorsOptions {
|
||||
/** Allowed origins: string, string array, true (reflect) or '*' */
|
||||
origin?: string | string[] | boolean
|
||||
|
||||
/** Allowed HTTP methods */
|
||||
methods?: string[]
|
||||
|
||||
/** Allowed request headers */
|
||||
allowedHeaders?: string[]
|
||||
|
||||
/** Allow credentials */
|
||||
credentials?: boolean
|
||||
|
||||
/** Preflight cache max age (seconds) */
|
||||
maxAge?: number
|
||||
}
|
||||
```
|
||||
|
||||
## Route Merging
|
||||
|
||||
File routes and inline routes can be used together, with inline routes having higher priority:
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
httpDir: './src/http',
|
||||
httpPrefix: '/api',
|
||||
|
||||
// Inline routes merge with file routes
|
||||
http: {
|
||||
'/health': (req, res) => res.json({ status: 'ok' }),
|
||||
'/api/special': (req, res) => res.json({ special: true })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Sharing Port with WebSocket
|
||||
|
||||
HTTP routes automatically share the same port with WebSocket services:
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
// WebSocket related config
|
||||
apiDir: './src/api',
|
||||
msgDir: './src/msg',
|
||||
|
||||
// HTTP related config
|
||||
httpDir: './src/http',
|
||||
httpPrefix: '/api',
|
||||
cors: true
|
||||
})
|
||||
|
||||
await server.start()
|
||||
|
||||
// Same port 3000:
|
||||
// - WebSocket: ws://localhost:3000
|
||||
// - HTTP API: http://localhost:3000/api/*
|
||||
```
|
||||
|
||||
## Complete Examples
|
||||
|
||||
### Game Server Login API
|
||||
|
||||
```typescript
|
||||
// src/http/auth/login.ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
import { createJwtAuthProvider } from '@esengine/server/auth'
|
||||
|
||||
interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
token: string
|
||||
userId: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const jwtProvider = createJwtAuthProvider({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600
|
||||
})
|
||||
|
||||
export default defineHttp<LoginRequest>({
|
||||
method: 'POST',
|
||||
async handler(req, res) {
|
||||
const { username, password } = req.body as LoginRequest
|
||||
|
||||
// Validate user
|
||||
const user = await db.users.findByUsername(username)
|
||||
if (!user || !await verifyPassword(password, user.passwordHash)) {
|
||||
res.error(401, 'Invalid username or password')
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
const token = jwtProvider.sign({
|
||||
sub: user.id,
|
||||
name: user.username,
|
||||
roles: user.roles
|
||||
})
|
||||
|
||||
const response: LoginResponse = {
|
||||
token,
|
||||
userId: user.id,
|
||||
expiresAt: Date.now() + 3600 * 1000
|
||||
}
|
||||
|
||||
res.json(response)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Game Data Query API
|
||||
|
||||
```typescript
|
||||
// src/http/game/leaderboard.ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
async handler(req, res) {
|
||||
const limit = parseInt(req.query.limit ?? '10')
|
||||
const offset = parseInt(req.query.offset ?? '0')
|
||||
|
||||
const players = await db.players.findMany({
|
||||
sort: { score: 'desc' },
|
||||
limit,
|
||||
offset
|
||||
})
|
||||
|
||||
res.json({
|
||||
data: players,
|
||||
pagination: { limit, offset }
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Middleware
|
||||
|
||||
### Middleware Type
|
||||
|
||||
Middleware are functions that execute before and after route handlers:
|
||||
|
||||
```typescript
|
||||
type HttpMiddleware = (
|
||||
req: HttpRequest,
|
||||
res: HttpResponse,
|
||||
next: () => Promise<void>
|
||||
) => void | Promise<void>
|
||||
```
|
||||
|
||||
### Built-in Middleware
|
||||
|
||||
```typescript
|
||||
import {
|
||||
requestLogger,
|
||||
bodyLimit,
|
||||
responseTime,
|
||||
requestId,
|
||||
securityHeaders
|
||||
} from '@esengine/server'
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
http: { /* ... */ },
|
||||
// Global middleware configured via createHttpRouter
|
||||
})
|
||||
```
|
||||
|
||||
#### requestLogger - Request Logging
|
||||
|
||||
```typescript
|
||||
import { requestLogger } from '@esengine/server'
|
||||
|
||||
// Log request and response time
|
||||
requestLogger()
|
||||
|
||||
// Also log request body
|
||||
requestLogger({ logBody: true })
|
||||
```
|
||||
|
||||
#### bodyLimit - Request Body Size Limit
|
||||
|
||||
```typescript
|
||||
import { bodyLimit } from '@esengine/server'
|
||||
|
||||
// Limit request body to 1MB
|
||||
bodyLimit(1024 * 1024)
|
||||
```
|
||||
|
||||
#### responseTime - Response Time Header
|
||||
|
||||
```typescript
|
||||
import { responseTime } from '@esengine/server'
|
||||
|
||||
// Automatically add X-Response-Time header
|
||||
responseTime()
|
||||
```
|
||||
|
||||
#### requestId - Request ID
|
||||
|
||||
```typescript
|
||||
import { requestId } from '@esengine/server'
|
||||
|
||||
// Auto-generate and add X-Request-ID header
|
||||
requestId()
|
||||
|
||||
// Custom header name
|
||||
requestId('X-Trace-ID')
|
||||
```
|
||||
|
||||
#### securityHeaders - Security Headers
|
||||
|
||||
```typescript
|
||||
import { securityHeaders } from '@esengine/server'
|
||||
|
||||
// Add common security response headers
|
||||
securityHeaders()
|
||||
|
||||
// Custom configuration
|
||||
securityHeaders({
|
||||
hidePoweredBy: true,
|
||||
frameOptions: 'DENY',
|
||||
noSniff: true
|
||||
})
|
||||
```
|
||||
|
||||
### Custom Middleware
|
||||
|
||||
```typescript
|
||||
import type { HttpMiddleware } from '@esengine/server'
|
||||
|
||||
// Authentication middleware
|
||||
const authMiddleware: HttpMiddleware = async (req, res, next) => {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '')
|
||||
|
||||
if (!token) {
|
||||
res.error(401, 'Unauthorized')
|
||||
return // Don't call next(), terminate request
|
||||
}
|
||||
|
||||
// Validate token...
|
||||
(req as any).userId = 'decoded-user-id'
|
||||
|
||||
await next() // Continue to next middleware and handler
|
||||
}
|
||||
```
|
||||
|
||||
### Using Middleware
|
||||
|
||||
#### With createHttpRouter
|
||||
|
||||
```typescript
|
||||
import { createHttpRouter, requestLogger, bodyLimit } from '@esengine/server'
|
||||
|
||||
const router = createHttpRouter({
|
||||
'/api/users': (req, res) => res.json([]),
|
||||
'/api/admin': {
|
||||
GET: {
|
||||
handler: (req, res) => res.json({ admin: true }),
|
||||
middlewares: [adminAuthMiddleware] // Route-level middleware
|
||||
}
|
||||
}
|
||||
}, {
|
||||
middlewares: [requestLogger(), bodyLimit(1024 * 1024)], // Global middleware
|
||||
timeout: 30000 // Global timeout 30 seconds
|
||||
})
|
||||
```
|
||||
|
||||
## Request Timeout
|
||||
|
||||
### Global Timeout
|
||||
|
||||
```typescript
|
||||
import { createHttpRouter } from '@esengine/server'
|
||||
|
||||
const router = createHttpRouter({
|
||||
'/api/data': async (req, res) => {
|
||||
// If processing exceeds 30 seconds, auto-return 408 Request Timeout
|
||||
await someSlowOperation()
|
||||
res.json({ data: 'result' })
|
||||
}
|
||||
}, {
|
||||
timeout: 30000 // 30 seconds
|
||||
})
|
||||
```
|
||||
|
||||
### Route-level Timeout
|
||||
|
||||
```typescript
|
||||
const router = createHttpRouter({
|
||||
'/api/quick': (req, res) => res.json({ fast: true }),
|
||||
|
||||
'/api/slow': {
|
||||
POST: {
|
||||
handler: async (req, res) => {
|
||||
await verySlowOperation()
|
||||
res.json({ done: true })
|
||||
},
|
||||
timeout: 120000 // This route allows 2 minutes
|
||||
}
|
||||
}
|
||||
}, {
|
||||
timeout: 10000 // Global 10 seconds (overridden by route-level)
|
||||
})
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use defineHttp** - Get better type hints and code organization
|
||||
2. **Unified Error Handling** - Use `res.error()` to return consistent error format
|
||||
3. **Enable CORS** - Required for frontend-backend separation
|
||||
4. **Directory Organization** - Organize HTTP route files by functional modules
|
||||
5. **Validate Input** - Always validate `req.body` and `req.query` content
|
||||
6. **Status Code Standards** - Follow HTTP status code conventions (200, 201, 400, 401, 404, 500, etc.)
|
||||
7. **Use Middleware** - Implement cross-cutting concerns like auth, logging, rate limiting via middleware
|
||||
8. **Set Timeouts** - Prevent slow requests from blocking the server
|
||||
@@ -147,6 +147,7 @@ service.on('chat', (data) => {
|
||||
|
||||
- [Client Usage](/en/modules/network/client/) - NetworkPlugin, components and systems
|
||||
- [Server Side](/en/modules/network/server/) - GameServer and Room management
|
||||
- [Distributed Rooms](/en/modules/network/distributed/) - Multi-server room management and player routing
|
||||
- [State Sync](/en/modules/network/sync/) - Interpolation and snapshot buffering
|
||||
- [Client Prediction](/en/modules/network/prediction/) - Input prediction and server reconciliation
|
||||
- [Area of Interest (AOI)](/en/modules/network/aoi/) - View filtering and bandwidth optimization
|
||||
|
||||
@@ -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';
|
||||
```
|
||||
|
||||
@@ -79,10 +79,33 @@ await server.start()
|
||||
| `tickRate` | `number` | `20` | Global tick rate (Hz) |
|
||||
| `apiDir` | `string` | `'src/api'` | API handlers directory |
|
||||
| `msgDir` | `string` | `'src/msg'` | Message handlers directory |
|
||||
| `httpDir` | `string` | `'src/http'` | HTTP routes directory |
|
||||
| `httpPrefix` | `string` | `'/api'` | HTTP routes prefix |
|
||||
| `cors` | `boolean \| CorsOptions` | - | CORS configuration |
|
||||
| `onStart` | `(port) => void` | - | Start callback |
|
||||
| `onConnect` | `(conn) => void` | - | Connection callback |
|
||||
| `onDisconnect` | `(conn) => void` | - | Disconnect callback |
|
||||
|
||||
## HTTP Routing
|
||||
|
||||
Supports HTTP API sharing the same port with WebSocket, ideal for login, registration, and similar scenarios.
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
httpDir: './src/http', // HTTP routes directory
|
||||
httpPrefix: '/api', // Route prefix
|
||||
cors: true,
|
||||
|
||||
// Or inline definition
|
||||
http: {
|
||||
'/health': (req, res) => res.json({ status: 'ok' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
> For detailed documentation, see [HTTP Routing](/en/modules/network/http)
|
||||
|
||||
## Room System
|
||||
|
||||
Room is the base class for game rooms, managing players and game state.
|
||||
@@ -243,6 +266,122 @@ class GameRoom extends Room {
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Validation
|
||||
|
||||
Use the built-in Schema validation system for runtime type validation:
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { s, defineApiWithSchema } from '@esengine/server'
|
||||
|
||||
// Define schema
|
||||
const MoveSchema = s.object({
|
||||
x: s.number(),
|
||||
y: s.number(),
|
||||
speed: s.number().optional()
|
||||
})
|
||||
|
||||
// Auto type inference
|
||||
type Move = s.infer<typeof MoveSchema> // { x: number; y: number; speed?: number }
|
||||
|
||||
// Use schema to define API (auto validation)
|
||||
export default defineApiWithSchema(MoveSchema, {
|
||||
handler(req, ctx) {
|
||||
// req is validated, type-safe
|
||||
console.log(req.x, req.y)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Validator Types
|
||||
|
||||
| Type | Example | Description |
|
||||
|------|---------|-------------|
|
||||
| `s.string()` | `s.string().min(1).max(50)` | String with length constraints |
|
||||
| `s.number()` | `s.number().min(0).int()` | Number with range and integer constraints |
|
||||
| `s.boolean()` | `s.boolean()` | Boolean |
|
||||
| `s.literal()` | `s.literal('admin')` | Literal type |
|
||||
| `s.object()` | `s.object({ name: s.string() })` | Object |
|
||||
| `s.array()` | `s.array(s.number())` | Array |
|
||||
| `s.enum()` | `s.enum(['a', 'b'] as const)` | Enum |
|
||||
| `s.union()` | `s.union([s.string(), s.number()])` | Union type |
|
||||
| `s.record()` | `s.record(s.any())` | Record type |
|
||||
|
||||
### Modifiers
|
||||
|
||||
```typescript
|
||||
// Optional field
|
||||
s.string().optional()
|
||||
|
||||
// Default value
|
||||
s.number().default(0)
|
||||
|
||||
// Nullable
|
||||
s.string().nullable()
|
||||
|
||||
// String validation
|
||||
s.string().min(1).max(100).email().url().regex(/^[a-z]+$/)
|
||||
|
||||
// Number validation
|
||||
s.number().min(0).max(100).int().positive()
|
||||
|
||||
// Array validation
|
||||
s.array(s.string()).min(1).max(10).nonempty()
|
||||
|
||||
// Object validation
|
||||
s.object({ ... }).strict() // No extra fields allowed
|
||||
s.object({ ... }).partial() // All fields optional
|
||||
s.object({ ... }).pick('name', 'age') // Pick fields
|
||||
s.object({ ... }).omit('password') // Omit fields
|
||||
```
|
||||
|
||||
### Message Validation
|
||||
|
||||
```typescript
|
||||
import { s, defineMsgWithSchema } from '@esengine/server'
|
||||
|
||||
const InputSchema = s.object({
|
||||
keys: s.array(s.string()),
|
||||
timestamp: s.number()
|
||||
})
|
||||
|
||||
export default defineMsgWithSchema(InputSchema, {
|
||||
handler(msg, ctx) {
|
||||
// msg is validated
|
||||
console.log(msg.keys, msg.timestamp)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Manual Validation
|
||||
|
||||
```typescript
|
||||
import { s, parse, safeParse, createGuard } from '@esengine/server'
|
||||
|
||||
const UserSchema = s.object({
|
||||
name: s.string(),
|
||||
age: s.number().int().min(0)
|
||||
})
|
||||
|
||||
// Throws on error
|
||||
const user = parse(UserSchema, data)
|
||||
|
||||
// Returns result object
|
||||
const result = safeParse(UserSchema, data)
|
||||
if (result.success) {
|
||||
console.log(result.data)
|
||||
} else {
|
||||
console.error(result.error)
|
||||
}
|
||||
|
||||
// Type guard
|
||||
const isUser = createGuard(UserSchema)
|
||||
if (isUser(data)) {
|
||||
// data is User type
|
||||
}
|
||||
```
|
||||
|
||||
## Protocol Definition
|
||||
|
||||
Define shared types in `src/shared/protocol.ts`:
|
||||
|
||||
@@ -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';
|
||||
```
|
||||
|
||||
@@ -125,23 +125,24 @@ tx:data:{key} - Business data
|
||||
|
||||
## MongoStorage
|
||||
|
||||
MongoDB storage, suitable for scenarios requiring persistence and complex queries. Uses factory pattern with lazy connection.
|
||||
MongoDB storage, suitable for scenarios requiring persistence and complex queries. Uses shared connection from `@esengine/database-drivers`.
|
||||
|
||||
```typescript
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { MongoStorage } from '@esengine/transaction';
|
||||
import { createMongoConnection } from '@esengine/database-drivers';
|
||||
import { createMongoStorage, TransactionManager } from '@esengine/transaction';
|
||||
|
||||
// Factory pattern: lazy connection, connects on first operation
|
||||
const storage = new MongoStorage({
|
||||
factory: async () => {
|
||||
const client = new MongoClient('mongodb://localhost:27017');
|
||||
await client.connect();
|
||||
return client;
|
||||
},
|
||||
database: 'game',
|
||||
transactionCollection: 'transactions', // Transaction log collection
|
||||
dataCollection: 'transaction_data', // Business data collection
|
||||
lockCollection: 'transaction_locks', // Lock collection
|
||||
// Create shared connection
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
});
|
||||
await mongo.connect();
|
||||
|
||||
// Create storage using shared connection
|
||||
const storage = createMongoStorage(mongo, {
|
||||
transactionCollection: 'transactions', // Transaction log collection (optional)
|
||||
dataCollection: 'transaction_data', // Business data collection (optional)
|
||||
lockCollection: 'transaction_locks', // Lock collection (optional)
|
||||
});
|
||||
|
||||
// Create indexes (run on first startup)
|
||||
@@ -149,11 +150,14 @@ await storage.ensureIndexes();
|
||||
|
||||
const manager = new TransactionManager({ storage });
|
||||
|
||||
// Close connection when done
|
||||
// Close storage (does not close shared connection)
|
||||
await storage.close();
|
||||
|
||||
// Or use await using for automatic cleanup (TypeScript 5.2+)
|
||||
await using storage = new MongoStorage({ ... });
|
||||
// Shared connection can continue to be used by other modules
|
||||
const userRepo = new UserRepository(mongo); // @esengine/database
|
||||
|
||||
// Finally close the shared connection
|
||||
await mongo.disconnect();
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
|
||||
@@ -434,6 +434,6 @@ const found = hierarchySystem.findChild(parent, "Child");
|
||||
|
||||
## 下一步
|
||||
|
||||
- 了解 [实体类](./entity/) 的其他功能
|
||||
- 了解 [场景管理](./scene/) 如何组织实体和系统
|
||||
- 了解 [组件系统](./component/) 如何定义和使用组件
|
||||
- 了解 [实体类](/guide/entity/) 的其他功能
|
||||
- 了解 [场景管理](/guide/scene/) 如何组织实体和系统
|
||||
- 了解 [组件系统](/guide/component/) 如何定义和使用组件
|
||||
|
||||
363
docs/src/content/docs/guide/persistent-entity.md
Normal file
363
docs/src/content/docs/guide/persistent-entity.md
Normal 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/) - 了解场景切换
|
||||
@@ -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 服务器环境提供支持,适用于游戏服务器和计算服务器。
|
||||
|
||||
|
||||
439
docs/src/content/docs/guide/scene-manager.md
Normal file
439
docs/src/content/docs/guide/scene-manager.md
Normal 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 的静态方法,你可以轻松管理场景切换。
|
||||
@@ -71,6 +71,55 @@ class ConfiguredScene extends Scene {
|
||||
}
|
||||
```
|
||||
|
||||
## 运行时环境
|
||||
|
||||
对于网络游戏,你可以配置运行时环境来区分服务端和客户端逻辑。
|
||||
|
||||
### 全局配置(推荐)
|
||||
|
||||
在 Core 层级设置一次运行时环境,所有场景都会继承此设置:
|
||||
|
||||
```typescript
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
|
||||
// 方式1:在 Core.create() 中设置
|
||||
Core.create({ runtimeEnvironment: 'server' });
|
||||
|
||||
// 方式2:直接设置静态属性
|
||||
Core.runtimeEnvironment = 'server';
|
||||
```
|
||||
|
||||
### 单个场景覆盖
|
||||
|
||||
个别场景可以覆盖全局设置:
|
||||
|
||||
```typescript
|
||||
const clientScene = new Scene({ runtimeEnvironment: 'client' });
|
||||
```
|
||||
|
||||
### 环境类型
|
||||
|
||||
| 环境 | 使用场景 |
|
||||
|------|----------|
|
||||
| `'standalone'` | 单机游戏(默认) |
|
||||
| `'server'` | 游戏服务器,权威逻辑 |
|
||||
| `'client'` | 游戏客户端,渲染/输入 |
|
||||
|
||||
### 在系统中检查环境
|
||||
|
||||
```typescript
|
||||
class CollectibleSpawnSystem extends EntitySystem {
|
||||
private checkCollections(): void {
|
||||
// 客户端跳过 - 只有服务端处理权威逻辑
|
||||
if (!this.scene.isServer) return;
|
||||
|
||||
// 服务端权威生成逻辑...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
参见 [系统运行时装饰器](/guide/system/index#运行时环境装饰器) 了解基于装饰器的方式。
|
||||
|
||||
### 运行场景
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -160,6 +160,53 @@ scene.addSystem(new SystemA()); // addOrder = 0,先执行
|
||||
scene.addSystem(new SystemB()); // addOrder = 1,后执行
|
||||
```
|
||||
|
||||
## 运行时环境装饰器
|
||||
|
||||
对于网络游戏,你可以使用装饰器来控制系统方法在哪个环境下执行。
|
||||
|
||||
### 可用装饰器
|
||||
|
||||
| 装饰器 | 效果 |
|
||||
|--------|------|
|
||||
| `@ServerOnly()` | 方法仅在服务端执行 |
|
||||
| `@ClientOnly()` | 方法仅在客户端执行 |
|
||||
| `@NotServer()` | 方法在服务端跳过 |
|
||||
| `@NotClient()` | 方法在客户端跳过 |
|
||||
|
||||
### 使用示例
|
||||
|
||||
```typescript
|
||||
import { EntitySystem, ServerOnly, ClientOnly } from '@esengine/ecs-framework';
|
||||
|
||||
class GameSystem extends EntitySystem {
|
||||
@ServerOnly()
|
||||
private spawnEnemies(): void {
|
||||
// 仅在服务端运行 - 权威生成逻辑
|
||||
}
|
||||
|
||||
@ClientOnly()
|
||||
private playEffects(): void {
|
||||
// 仅在客户端运行 - 视觉效果
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 简单条件检查
|
||||
|
||||
对于简单场景,直接检查通常比装饰器更清晰:
|
||||
|
||||
```typescript
|
||||
class CollectibleSystem extends EntitySystem {
|
||||
private checkCollections(): void {
|
||||
if (!this.scene.isServer) return; // 客户端跳过
|
||||
|
||||
// 服务端权威逻辑...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
参见 [场景运行时环境](/guide/scene/index#运行时环境) 了解配置详情。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [系统类型](/guide/system/types) - 了解不同类型的系统基类
|
||||
|
||||
@@ -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/)学习可视化编辑
|
||||
|
||||
@@ -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/)优化你的行为树设计
|
||||
|
||||
@@ -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/)深入理解原理
|
||||
|
||||
@@ -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/)创建自定义行为
|
||||
|
||||
@@ -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/)学习设计模式
|
||||
|
||||
@@ -606,6 +606,107 @@ export class RetryDecorator implements INodeExecutor {
|
||||
}
|
||||
```
|
||||
|
||||
## 在代码中使用自定义执行器
|
||||
|
||||
定义了自定义执行器后,可以通过 `BehaviorTreeBuilder` 的 `.action()` 和 `.condition()` 方法在代码中使用:
|
||||
|
||||
### 使用 action() 方法
|
||||
|
||||
```typescript
|
||||
import { BehaviorTreeBuilder, BehaviorTreeStarter } from '@esengine/behavior-tree';
|
||||
|
||||
// 使用自定义执行器构建行为树
|
||||
const tree = BehaviorTreeBuilder.create('CombatAI')
|
||||
.defineBlackboardVariable('health', 100)
|
||||
.defineBlackboardVariable('target', null)
|
||||
.selector('Root')
|
||||
.sequence('AttackSequence')
|
||||
// 使用自定义动作 - implementationType 匹配装饰器中的定义
|
||||
.action('AttackAction', 'Attack', { damage: 25 })
|
||||
.action('MoveToPosition', 'Chase', { speed: 10 })
|
||||
.end()
|
||||
.action('DelayAction', 'Idle', { duration: 1.0 })
|
||||
.end()
|
||||
.build();
|
||||
|
||||
// 启动行为树
|
||||
const entity = scene.createEntity('Enemy');
|
||||
BehaviorTreeStarter.start(entity, tree);
|
||||
```
|
||||
|
||||
### 使用 condition() 方法
|
||||
|
||||
```typescript
|
||||
const tree = BehaviorTreeBuilder.create('AI')
|
||||
.selector('Root')
|
||||
.sequence('AttackBranch')
|
||||
// 使用自定义条件
|
||||
.condition('CheckHealth', 'IsHealthy', { threshold: 50, operator: 'greater' })
|
||||
.action('AttackAction', 'Attack')
|
||||
.end()
|
||||
.end()
|
||||
.build();
|
||||
```
|
||||
|
||||
### Builder 方法对照表
|
||||
|
||||
| 方法 | 说明 | 使用场景 |
|
||||
|------|------|----------|
|
||||
| `.action(type, name?, config?)` | 使用自定义动作执行器 | 自定义 Action 类 |
|
||||
| `.condition(type, name?, config?)` | 使用自定义条件执行器 | 自定义 Condition 类 |
|
||||
| `.executeAction(name)` | 调用黑板函数 `action_{name}` | 简单逻辑、快速原型 |
|
||||
| `.executeCondition(name)` | 调用黑板函数 `condition_{name}` | 简单条件判断 |
|
||||
|
||||
### 完整示例
|
||||
|
||||
```typescript
|
||||
import {
|
||||
BehaviorTreeBuilder,
|
||||
BehaviorTreeStarter,
|
||||
NodeExecutorMetadata,
|
||||
INodeExecutor,
|
||||
NodeExecutionContext,
|
||||
TaskStatus,
|
||||
NodeType,
|
||||
BindingHelper
|
||||
} from '@esengine/behavior-tree';
|
||||
|
||||
// 1. 定义自定义执行器
|
||||
@NodeExecutorMetadata({
|
||||
implementationType: 'AttackAction',
|
||||
nodeType: NodeType.Action,
|
||||
displayName: '攻击',
|
||||
category: 'Combat',
|
||||
configSchema: {
|
||||
damage: { type: 'number', default: 10, supportBinding: true }
|
||||
}
|
||||
})
|
||||
class AttackAction implements INodeExecutor {
|
||||
execute(context: NodeExecutionContext): TaskStatus {
|
||||
const damage = BindingHelper.getValue<number>(context, 'damage', 10);
|
||||
console.log(`执行攻击,造成 ${damage} 点伤害!`);
|
||||
return TaskStatus.Success;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 构建行为树
|
||||
const enemyAI = BehaviorTreeBuilder.create('EnemyAI')
|
||||
.defineBlackboardVariable('health', 100)
|
||||
.defineBlackboardVariable('target', null)
|
||||
.selector('MainBehavior')
|
||||
.sequence('AttackBranch')
|
||||
.condition('CheckHealth', 'HasEnoughHealth', { threshold: 20, operator: 'greater' })
|
||||
.action('AttackAction', 'Attack', { damage: 50 })
|
||||
.end()
|
||||
.log('逃跑', 'Flee')
|
||||
.end()
|
||||
.build();
|
||||
|
||||
// 3. 启动行为树
|
||||
const entity = scene.createEntity('Enemy');
|
||||
BehaviorTreeStarter.start(entity, enemyAI);
|
||||
```
|
||||
|
||||
## 注册执行器
|
||||
|
||||
### 自动注册
|
||||
@@ -1022,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/)了解更多功能
|
||||
|
||||
@@ -117,5 +117,5 @@ BehaviorTreeStarter.start(entity, tree);
|
||||
|
||||
## 下一步
|
||||
|
||||
- 查看[编辑器工作流](./editor-workflow/)了解完整的开发流程
|
||||
- 查看[自定义节点执行器](./custom-actions/)学习如何扩展节点
|
||||
- 查看[编辑器工作流](/modules/behavior-tree/editor-workflow/)了解完整的开发流程
|
||||
- 查看[自定义节点执行器](/modules/behavior-tree/custom-actions/)学习如何扩展节点
|
||||
|
||||
@@ -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设计
|
||||
|
||||
@@ -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/)学习如何创建。
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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
|
||||
|
||||
383
docs/src/content/docs/modules/blueprint/cocos-editor.md
Normal file
383
docs/src/content/docs/modules/blueprint/cocos-editor.md
Normal file
@@ -0,0 +1,383 @@
|
||||
---
|
||||
title: "Cocos Creator 蓝图编辑器"
|
||||
description: "在 Cocos Creator 中使用蓝图可视化脚本系统"
|
||||
---
|
||||
|
||||
本文档介绍如何在 Cocos Creator 项目中安装和使用蓝图可视化脚本编辑器扩展。
|
||||
|
||||
## 安装扩展
|
||||
|
||||
### 1. 复制扩展到项目
|
||||
|
||||
将 `cocos-node-editor` 扩展复制到你的 Cocos Creator 项目的 `extensions` 目录:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── assets/
|
||||
├── extensions/
|
||||
│ └── cocos-node-editor/ # 蓝图编辑器扩展
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
在扩展目录中安装依赖:
|
||||
|
||||
```bash
|
||||
cd extensions/cocos-node-editor
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. 启用扩展
|
||||
|
||||
1. 打开 Cocos Creator
|
||||
2. 进入 **扩展 → 扩展管理器**
|
||||
3. 找到 `cocos-node-editor` 并启用
|
||||
|
||||
## 打开蓝图编辑器
|
||||
|
||||
通过菜单 **面板 → Node Editor** 打开蓝图编辑器面板。
|
||||
|
||||
## 编辑器界面
|
||||
|
||||
### 工具栏
|
||||
|
||||
| 按钮 | 快捷键 | 功能 |
|
||||
|------|--------|------|
|
||||
| 新建 | - | 创建空白蓝图 |
|
||||
| 加载 | - | 从文件加载蓝图 |
|
||||
| 保存 | `Ctrl+S` | 保存蓝图到文件 |
|
||||
| 撤销 | `Ctrl+Z` | 撤销上一步操作 |
|
||||
| 重做 | `Ctrl+Shift+Z` | 重做操作 |
|
||||
| 剪切 | `Ctrl+X` | 剪切选中节点 |
|
||||
| 复制 | `Ctrl+C` | 复制选中节点 |
|
||||
| 粘贴 | `Ctrl+V` | 粘贴节点 |
|
||||
| 删除 | `Delete` | 删除选中项 |
|
||||
| 重新扫描 | - | 重新扫描项目中的蓝图节点 |
|
||||
|
||||
### 画布操作
|
||||
|
||||
- **右键单击画布**:打开节点添加菜单
|
||||
- **拖拽节点**:移动节点位置
|
||||
- **点击节点**:选中节点
|
||||
- **Ctrl+点击**:多选节点
|
||||
- **拖拽引脚到引脚**:创建连接
|
||||
- **滚轮**:缩放画布
|
||||
- **中键拖拽**:平移画布
|
||||
|
||||
### 节点菜单
|
||||
|
||||
右键单击画布后会显示节点菜单:
|
||||
|
||||
- 顶部搜索框可以快速搜索节点
|
||||
- 节点按类别分组显示
|
||||
- 按 `Enter` 快速添加第一个搜索结果
|
||||
- 按 `Esc` 关闭菜单
|
||||
|
||||
## 蓝图文件格式
|
||||
|
||||
蓝图保存为 `.blueprint.json` 文件,格式与运行时完全兼容:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"type": "blueprint",
|
||||
"metadata": {
|
||||
"name": "My Blueprint",
|
||||
"createdAt": 1704307200000,
|
||||
"modifiedAt": 1704307200000
|
||||
},
|
||||
"variables": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"type": "PrintString",
|
||||
"position": { "x": 100, "y": 200 },
|
||||
"data": {}
|
||||
}
|
||||
],
|
||||
"connections": [
|
||||
{
|
||||
"id": "conn-1",
|
||||
"fromNodeId": "node-1",
|
||||
"fromPin": "exec",
|
||||
"toNodeId": "node-2",
|
||||
"toPin": "exec"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 在游戏中运行蓝图
|
||||
|
||||
使用 ECS 系统方式管理和执行蓝图。
|
||||
|
||||
### 1. 定义蓝图组件
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent, Property, Serialize } from '@esengine/ecs-framework';
|
||||
import type { BlueprintAsset } from '@esengine/blueprint';
|
||||
|
||||
@ECSComponent('Blueprint')
|
||||
export class BlueprintComponent extends Component {
|
||||
@Serialize()
|
||||
@Property({ type: 'asset', label: 'Blueprint Asset' })
|
||||
blueprintPath: string = '';
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Auto Start' })
|
||||
autoStart: boolean = true;
|
||||
|
||||
// 运行时数据(不序列化)
|
||||
blueprintAsset: BlueprintAsset | null = null;
|
||||
vm: BlueprintVM | null = null;
|
||||
isStarted: boolean = false;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 创建蓝图执行系统
|
||||
|
||||
```typescript
|
||||
import { EntitySystem, Matcher, Entity } from '@esengine/ecs-framework';
|
||||
import {
|
||||
BlueprintVM,
|
||||
validateBlueprintAsset
|
||||
} from '@esengine/blueprint';
|
||||
import { BlueprintComponent } from './BlueprintComponent';
|
||||
|
||||
export class BlueprintExecutionSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(BlueprintComponent));
|
||||
}
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
const dt = Time.deltaTime;
|
||||
|
||||
for (const entity of entities) {
|
||||
const bp = entity.getComponent(BlueprintComponent)!;
|
||||
|
||||
// 跳过没有蓝图资产的实体
|
||||
if (!bp.blueprintAsset) continue;
|
||||
|
||||
// 初始化 VM
|
||||
if (!bp.vm) {
|
||||
bp.vm = new BlueprintVM(bp.blueprintAsset, entity, this.scene!);
|
||||
}
|
||||
|
||||
// 自动启动
|
||||
if (bp.autoStart && !bp.isStarted) {
|
||||
bp.vm.start();
|
||||
bp.isStarted = true;
|
||||
}
|
||||
|
||||
// 更新蓝图
|
||||
if (bp.isStarted) {
|
||||
bp.vm.tick(dt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override onRemoved(entity: Entity): void {
|
||||
const bp = entity.getComponent(BlueprintComponent);
|
||||
if (bp?.vm && bp.isStarted) {
|
||||
bp.vm.stop();
|
||||
bp.vm = null;
|
||||
bp.isStarted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 加载蓝图并添加到实体
|
||||
|
||||
```typescript
|
||||
import { resources, JsonAsset } from 'cc';
|
||||
import { validateBlueprintAsset } from '@esengine/blueprint';
|
||||
|
||||
// 加载蓝图资产
|
||||
async function loadBlueprint(path: string): Promise<BlueprintAsset | null> {
|
||||
return new Promise((resolve) => {
|
||||
resources.load(path, JsonAsset, (err, asset) => {
|
||||
if (err || !asset) {
|
||||
console.error('Failed to load blueprint:', err);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = asset.json;
|
||||
if (validateBlueprintAsset(data)) {
|
||||
resolve(data as BlueprintAsset);
|
||||
} else {
|
||||
console.error('Invalid blueprint format');
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 创建带蓝图的实体
|
||||
async function createBlueprintEntity(scene: IScene, blueprintPath: string): Promise<Entity> {
|
||||
const entity = scene.createEntity('BlueprintEntity');
|
||||
|
||||
const bpComponent = entity.addComponent(BlueprintComponent);
|
||||
bpComponent.blueprintPath = blueprintPath;
|
||||
bpComponent.blueprintAsset = await loadBlueprint(blueprintPath);
|
||||
|
||||
return entity;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 注册系统到场景
|
||||
|
||||
```typescript
|
||||
// 在场景初始化时
|
||||
scene.addSystem(new BlueprintExecutionSystem());
|
||||
```
|
||||
|
||||
## 创建自定义节点
|
||||
|
||||
### 使用装饰器标记组件
|
||||
|
||||
推荐使用装饰器让组件自动生成蓝图节点:
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent } from '@esengine/ecs-framework';
|
||||
import { BlueprintExpose, BlueprintProperty, BlueprintMethod } from '@esengine/blueprint';
|
||||
|
||||
@ECSComponent('Health')
|
||||
@BlueprintExpose({ displayName: '生命值组件' })
|
||||
export class HealthComponent extends Component {
|
||||
@BlueprintProperty({ displayName: '当前生命值', category: 'number' })
|
||||
current: number = 100;
|
||||
|
||||
@BlueprintProperty({ displayName: '最大生命值', category: 'number' })
|
||||
max: number = 100;
|
||||
|
||||
@BlueprintMethod({ displayName: '治疗', isExec: true })
|
||||
heal(amount: number): void {
|
||||
this.current = Math.min(this.current + amount, this.max);
|
||||
}
|
||||
|
||||
@BlueprintMethod({ displayName: '受伤', isExec: true })
|
||||
takeDamage(amount: number): void {
|
||||
this.current = Math.max(this.current - amount, 0);
|
||||
}
|
||||
|
||||
@BlueprintMethod({ displayName: '是否死亡' })
|
||||
isDead(): boolean {
|
||||
return this.current <= 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 注册组件节点
|
||||
|
||||
```typescript
|
||||
import { registerAllComponentNodes } from '@esengine/blueprint';
|
||||
|
||||
// 在应用启动时注册所有标记的组件
|
||||
registerAllComponentNodes();
|
||||
```
|
||||
|
||||
### 手动定义节点(高级)
|
||||
|
||||
如需完全自定义节点逻辑:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
BlueprintNodeTemplate,
|
||||
INodeExecutor,
|
||||
RegisterNode,
|
||||
ExecutionContext,
|
||||
ExecutionResult
|
||||
} from '@esengine/blueprint';
|
||||
|
||||
const MyNodeTemplate: BlueprintNodeTemplate = {
|
||||
type: 'MyCustomNode',
|
||||
title: '我的自定义节点',
|
||||
category: 'custom',
|
||||
description: '自定义节点示例',
|
||||
inputs: [
|
||||
{ name: 'exec', type: 'exec', direction: 'input', isExec: true },
|
||||
{ name: 'value', type: 'number', direction: 'input', defaultValue: 0 }
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'exec', type: 'exec', direction: 'output', isExec: true },
|
||||
{ name: 'result', type: 'number', direction: 'output' }
|
||||
]
|
||||
};
|
||||
|
||||
@RegisterNode(MyNodeTemplate)
|
||||
class MyNodeExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
const value = context.getInput<number>(node.id, 'value');
|
||||
return {
|
||||
outputs: { result: value * 2 },
|
||||
nextExec: 'exec'
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 节点类别
|
||||
|
||||
| 类别 | 说明 | 颜色 |
|
||||
|------|------|------|
|
||||
| `event` | 事件节点 | 红色 |
|
||||
| `flow` | 流程控制 | 灰色 |
|
||||
| `entity` | 实体操作 | 蓝色 |
|
||||
| `component` | 组件访问 | 青色 |
|
||||
| `math` | 数学运算 | 绿色 |
|
||||
| `logic` | 逻辑运算 | 红色 |
|
||||
| `variable` | 变量访问 | 紫色 |
|
||||
| `time` | 时间工具 | 青色 |
|
||||
| `debug` | 调试工具 | 灰色 |
|
||||
| `custom` | 自定义节点 | 蓝灰色 |
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **文件组织**
|
||||
- 将蓝图文件放在 `assets/blueprints/` 目录下
|
||||
- 使用有意义的文件名,如 `player-controller.blueprint.json`
|
||||
|
||||
2. **组件设计**
|
||||
- 使用 `@BlueprintExpose` 标记需要暴露给蓝图的组件
|
||||
- 为属性和方法提供清晰的 `displayName`
|
||||
- 将执行方法标记为 `isExec: true`
|
||||
|
||||
3. **性能考虑**
|
||||
- 避免在 Tick 事件中执行重计算
|
||||
- 使用变量缓存中间结果
|
||||
- 纯函数节点会自动缓存输出
|
||||
|
||||
4. **调试技巧**
|
||||
- 使用 Print 节点输出中间值
|
||||
- 启用 `vm.debug = true` 查看执行日志
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 节点菜单是空的?
|
||||
|
||||
A: 点击 **重新扫描** 按钮扫描项目中的蓝图节点类。确保已调用 `registerAllComponentNodes()`。
|
||||
|
||||
### Q: 蓝图不执行?
|
||||
|
||||
A: 检查:
|
||||
1. 实体是否添加了 `BlueprintComponent`
|
||||
2. `BlueprintExecutionSystem` 是否注册到场景
|
||||
3. `blueprintAsset` 是否正确加载
|
||||
4. `autoStart` 是否为 `true`
|
||||
|
||||
### Q: 如何触发自定义事件?
|
||||
|
||||
A: 通过 VM 触发:
|
||||
```typescript
|
||||
const bp = entity.getComponent(BlueprintComponent);
|
||||
bp.vm?.triggerCustomEvent('OnPickup', { item: itemEntity });
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [蓝图运行时 API](/modules/blueprint/) - BlueprintVM 和核心 API
|
||||
- [自定义节点](/modules/blueprint/custom-nodes) - 详细的节点创建指南
|
||||
- [内置节点](/modules/blueprint/nodes) - 内置节点参考
|
||||
@@ -28,13 +28,13 @@ const MyNodeTemplate: BlueprintNodeTemplate = {
|
||||
## 实现节点执行器
|
||||
|
||||
```typescript
|
||||
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
|
||||
import { INodeExecutor, RegisterNode, BlueprintNode, ExecutionContext, ExecutionResult } from '@esengine/blueprint';
|
||||
|
||||
@RegisterNode(MyNodeTemplate)
|
||||
class MyNodeExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
// 获取输入
|
||||
const value = context.getInput<number>(node.id, 'value');
|
||||
// 获取输入(使用 evaluateInput)
|
||||
const value = context.evaluateInput(node.id, 'value', 0) as number;
|
||||
|
||||
// 执行逻辑
|
||||
const result = value * 2;
|
||||
@@ -100,29 +100,58 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
|
||||
};
|
||||
```
|
||||
|
||||
## 实际示例:输入处理节点
|
||||
## 实际示例:ECS 组件操作节点
|
||||
|
||||
```typescript
|
||||
const InputMoveTemplate: BlueprintNodeTemplate = {
|
||||
type: 'InputMove',
|
||||
title: 'Get Movement Input',
|
||||
category: 'input',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'direction', type: 'vector2', direction: 'output' }
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import { BlueprintNodeTemplate, BlueprintNode } from '@esengine/blueprint';
|
||||
import { ExecutionContext, ExecutionResult } from '@esengine/blueprint';
|
||||
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
|
||||
|
||||
// 自定义治疗节点
|
||||
const HealEntityTemplate: BlueprintNodeTemplate = {
|
||||
type: 'HealEntity',
|
||||
title: 'Heal Entity',
|
||||
category: 'gameplay',
|
||||
color: '#22aa22',
|
||||
description: 'Heal an entity with HealthComponent',
|
||||
keywords: ['heal', 'health', 'restore'],
|
||||
menuPath: ['Gameplay', 'Combat', 'Heal Entity'],
|
||||
inputs: [
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'entity', type: 'entity', displayName: 'Target' },
|
||||
{ name: 'amount', type: 'float', displayName: 'Amount', defaultValue: 10 }
|
||||
],
|
||||
isPure: true
|
||||
outputs: [
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'newHealth', type: 'float', displayName: 'New Health' }
|
||||
]
|
||||
};
|
||||
|
||||
@RegisterNode(InputMoveTemplate)
|
||||
class InputMoveExecutor implements INodeExecutor {
|
||||
@RegisterNode(HealEntityTemplate)
|
||||
class HealEntityExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
const input = context.scene.services.get(InputServiceToken);
|
||||
const direction = {
|
||||
x: input.getAxis('horizontal'),
|
||||
y: input.getAxis('vertical')
|
||||
};
|
||||
return { outputs: { direction } };
|
||||
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
|
||||
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
|
||||
|
||||
if (!entity || entity.isDestroyed) {
|
||||
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
|
||||
}
|
||||
|
||||
// 获取 HealthComponent
|
||||
const health = entity.components.find(c =>
|
||||
(c.constructor as any).__componentName__ === 'Health'
|
||||
) as any;
|
||||
|
||||
if (health) {
|
||||
health.current = Math.min(health.current + amount, health.max);
|
||||
return {
|
||||
outputs: { newHealth: health.current },
|
||||
nextExec: 'exec'
|
||||
};
|
||||
}
|
||||
|
||||
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
876
docs/src/content/docs/modules/blueprint/editor-guide.md
Normal file
876
docs/src/content/docs/modules/blueprint/editor-guide.md
Normal file
@@ -0,0 +1,876 @@
|
||||
---
|
||||
title: "蓝图编辑器使用指南"
|
||||
description: "Cocos Creator 蓝图可视化脚本编辑器完整使用教程"
|
||||
---
|
||||
|
||||
<script src="/js/blueprint-graph.js"></script>
|
||||
|
||||
本指南介绍如何在 Cocos Creator 中使用蓝图可视化脚本编辑器。
|
||||
|
||||
## 下载与安装
|
||||
|
||||
### 下载
|
||||
|
||||
从 GitHub Release 下载最新版本(免费):
|
||||
|
||||
**[下载 Cocos Node Editor v1.1.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.1.0)**
|
||||
|
||||
> 技术交流 QQ 群:**481923584** | 官网:[esengine.cn](https://esengine.cn/)
|
||||
|
||||
### 安装步骤
|
||||
|
||||
1. 解压 `cocos-node-editor.zip` 到项目的 `extensions` 目录:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── assets/
|
||||
├── extensions/
|
||||
│ └── cocos-node-editor/ ← 解压到这里
|
||||
└── ...
|
||||
```
|
||||
|
||||
2. 重启 Cocos Creator
|
||||
|
||||
3. 通过菜单 **扩展 → 扩展管理器** 确认插件已启用
|
||||
|
||||
4. 通过菜单 **面板 → Node Editor** 打开编辑器
|
||||
|
||||
## 界面介绍
|
||||
|
||||
- **工具栏** - 位于顶部,包含新建、打开、保存、撤销、重做等操作
|
||||
- **变量面板** - 位于左上角,用于定义和管理蓝图变量
|
||||
- **画布区域** - 主区域,用于放置和连接节点
|
||||
- **节点菜单** - 右键点击画布空白处打开,按分类列出所有可用节点
|
||||
|
||||
## 画布操作
|
||||
|
||||
| 操作 | 方式 |
|
||||
|------|------|
|
||||
| 平移画布 | 鼠标中键拖拽 / Alt + 左键拖拽 |
|
||||
| 缩放画布 | 鼠标滚轮 |
|
||||
| 打开节点菜单 | 右键点击空白处 |
|
||||
| 框选多个节点 | 在空白处拖拽 |
|
||||
| 追加框选 | Ctrl + 拖拽 |
|
||||
| 删除选中 | Delete 键 |
|
||||
|
||||
## 节点操作
|
||||
|
||||
### 添加节点
|
||||
|
||||
1. **从节点面板拖拽** - 将节点从左侧面板拖到画布
|
||||
2. **右键菜单** - 右键点击画布空白处,选择节点
|
||||
|
||||
### 连接节点
|
||||
|
||||
1. 从输出引脚拖拽到输入引脚
|
||||
2. 兼容类型的引脚会高亮显示
|
||||
3. 松开鼠标完成连接
|
||||
|
||||
**引脚类型说明:**
|
||||
|
||||
| 引脚颜色 | 类型 | 说明 |
|
||||
|---------|------|------|
|
||||
| 白色 ▶ | Exec | 执行流程(控制执行顺序) |
|
||||
| 青色 ◆ | Entity | 实体引用 |
|
||||
| 紫色 ◆ | Component | 组件引用 |
|
||||
| 浅蓝 ◆ | String | 字符串 |
|
||||
| 绿色 ◆ | Number | 数值 |
|
||||
| 红色 ◆ | Boolean | 布尔值 |
|
||||
| 灰色 ◆ | Any | 任意类型 |
|
||||
|
||||
### 删除连接
|
||||
|
||||
点击连接线选中,按 Delete 键删除。
|
||||
|
||||
## 节点类型详解
|
||||
|
||||
### 事件节点 (Event)
|
||||
|
||||
事件节点是蓝图的入口点,当特定事件发生时触发执行。
|
||||
|
||||
| 节点 | 触发时机 | 输出 |
|
||||
|------|---------|------|
|
||||
| **Event BeginPlay** | 蓝图开始运行时 | Exec, Self (实体) |
|
||||
| **Event Tick** | 每帧执行 | Exec, Delta Time |
|
||||
| **Event EndPlay** | 蓝图停止时 | Exec |
|
||||
|
||||
**示例:游戏开始时打印消息**
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"eg1-exec","to":"eg1-print","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="eg1-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Self</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg1-print"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Message</span>
|
||||
<span class="bp-pin-value">"游戏开始!"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### 实体节点 (Entity)
|
||||
|
||||
操作 ECS 实体的节点。
|
||||
|
||||
| 节点 | 功能 | 输入 | 输出 |
|
||||
|------|------|------|------|
|
||||
| **Get Self** | 获取当前实体 | - | Entity |
|
||||
| **Create Entity** | 创建新实体 | Exec, Name | Exec, Entity |
|
||||
| **Destroy Entity** | 销毁实体 | Exec, Entity | Exec |
|
||||
| **Find Entity By Name** | 按名称查找 | Name | Entity |
|
||||
| **Find Entities By Tag** | 按标签查找 | Tag | Entity[] |
|
||||
| **Is Valid** | 检查实体有效性 | Entity | Boolean |
|
||||
| **Get/Set Entity Name** | 获取/设置名称 | Entity | String |
|
||||
| **Set Active** | 设置激活状态 | Exec, Entity, Active | Exec |
|
||||
|
||||
**示例:创建新实体**
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"eg2-exec","to":"eg2-create","type":"exec"},{"from":"eg2-create-out","to":"eg2-add","type":"exec"},{"from":"eg2-entity","to":"eg2-add-entity","type":"entity"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="eg2-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Create Entity</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg2-create"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Name</span>
|
||||
<span class="bp-pin-value">"Bullet"</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg2-create-out"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg2-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 520px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Add Transform</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg2-add"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg2-add-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### 组件节点 (Component)
|
||||
|
||||
访问和操作 ECS 组件。
|
||||
|
||||
| 节点 | 功能 |
|
||||
|------|------|
|
||||
| **Has Component** | 检查实体是否有指定组件 |
|
||||
| **Get Component** | 获取组件实例 |
|
||||
| **Add Component** | 添加组件到实体 |
|
||||
| **Remove Component** | 移除组件 |
|
||||
| **Get/Set Property** | 获取/设置组件属性 |
|
||||
|
||||
**示例:修改 Transform 组件**
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"eg3-self","to":"eg3-getcomp","type":"entity"},{"from":"eg3-comp","to":"eg3-setprop","type":"component"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 100px;">
|
||||
<div class="bp-node-header pure">Get Self</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg3-self"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 200px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header pure">Get Component</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg3-getcomp"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg3-comp"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
|
||||
<span class="bp-pin-label">Transform</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 430px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header function">Set Property</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg3-setprop"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
|
||||
<span class="bp-pin-label">Target</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">x</span>
|
||||
<span class="bp-pin-value">100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### 流程控制节点 (Flow)
|
||||
|
||||
控制执行流程的节点。
|
||||
|
||||
#### Branch (分支)
|
||||
|
||||
条件判断,类似 if/else。
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"eg4-true","to":"eg4-do1","type":"exec"},{"from":"eg4-false","to":"eg4-do2","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 50px; width: 110px;">
|
||||
<div class="bp-node-header flow">Branch</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#8c0000" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Condition</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg4-true"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">True</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg4-false"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">False</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 220px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header function">DoSomething</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg4-do1"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 220px; top: 110px; width: 130px;">
|
||||
<div class="bp-node-header function">DoOtherThing</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg4-do2"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
#### Sequence (序列)
|
||||
|
||||
按顺序执行多个分支。
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"eg5-then0","to":"eg5-step1","type":"exec"},{"from":"eg5-then1","to":"eg5-step2","type":"exec"},{"from":"eg5-then2","to":"eg5-step3","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 110px;">
|
||||
<div class="bp-node-header flow">Sequence</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg5-then0"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Then 0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg5-then1"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Then 1</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg5-then2"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Then 2</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 220px; top: 20px; width: 100px;">
|
||||
<div class="bp-node-header function">Step 1</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg5-step1"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 220px; top: 100px; width: 100px;">
|
||||
<div class="bp-node-header function">Step 2</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg5-step2"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 220px; top: 180px; width: 100px;">
|
||||
<div class="bp-node-header function">Step 3</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg5-step3"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
#### For Loop (循环)
|
||||
|
||||
循环执行指定次数。
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"eg6-body","to":"eg6-iter","type":"exec"},{"from":"eg6-done","to":"eg6-finish","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
|
||||
<div class="bp-node-header flow">For Loop</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">First Index</span>
|
||||
<span class="bp-pin-value">0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Last Index</span>
|
||||
<span class="bp-pin-value">10</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg6-body"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Loop Body</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">Index</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg6-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 250px; top: 80px; width: 130px;">
|
||||
<div class="bp-node-header function">每次迭代执行</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg6-iter"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 250px; top: 160px; width: 140px;">
|
||||
<div class="bp-node-header function">循环结束后执行</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg6-finish"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
#### For Each (遍历)
|
||||
|
||||
遍历数组元素。
|
||||
|
||||
#### While Loop (条件循环)
|
||||
|
||||
当条件为真时持续循环。
|
||||
|
||||
#### Do Once (单次执行)
|
||||
|
||||
只执行一次,之后跳过。
|
||||
|
||||
#### Flip Flop (交替执行)
|
||||
|
||||
每次执行时交替触发 A 和 B 输出。
|
||||
|
||||
#### Gate (门)
|
||||
|
||||
可通过 Open/Close/Toggle 控制是否允许执行通过。
|
||||
|
||||
### 时间节点 (Time)
|
||||
|
||||
| 节点 | 功能 | 输出类型 |
|
||||
|------|------|---------|
|
||||
| **Delay** | 延迟指定时间后继续执行 | Exec |
|
||||
| **Get Delta Time** | 获取帧间隔时间 | Number |
|
||||
| **Get Time** | 获取运行总时间 | Number |
|
||||
|
||||
**示例:延迟 2 秒后执行**
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"eg7-exec","to":"eg7-delay","type":"exec"},{"from":"eg7-done","to":"eg7-print","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="eg7-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header time">Delay</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg7-delay"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Duration</span>
|
||||
<span class="bp-pin-value">2.0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="eg7-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 490px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="eg7-print"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Msg</span>
|
||||
<span class="bp-pin-value">"2秒后执行"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### 数学节点 (Math)
|
||||
|
||||
| 节点 | 功能 |
|
||||
|------|------|
|
||||
| **Add / Subtract / Multiply / Divide** | 四则运算 |
|
||||
| **Abs** | 绝对值 |
|
||||
| **Clamp** | 限制在范围内 |
|
||||
| **Lerp** | 线性插值 |
|
||||
| **Min / Max** | 最小/最大值 |
|
||||
| **Random Range** | 随机数 |
|
||||
| **Sin / Cos / Tan** | 三角函数 |
|
||||
|
||||
### 调试节点 (Debug)
|
||||
|
||||
| 节点 | 功能 |
|
||||
|------|------|
|
||||
| **Print** | 输出到控制台 |
|
||||
|
||||
## 变量系统
|
||||
|
||||
变量用于在蓝图中存储和共享数据。
|
||||
|
||||
### 创建变量
|
||||
|
||||
1. 在变量面板点击 **+** 按钮
|
||||
2. 输入变量名称
|
||||
3. 选择变量类型
|
||||
4. 设置默认值(可选)
|
||||
|
||||
### 使用变量
|
||||
|
||||
- **拖拽到画布** - 创建 Get 或 Set 节点
|
||||
- **Get 节点** - 读取变量值
|
||||
- **Set 节点** - 写入变量值
|
||||
|
||||
### 变量类型
|
||||
|
||||
| 类型 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| Boolean | 布尔值 | false |
|
||||
| Number | 数值 | 0 |
|
||||
| String | 字符串 | "" |
|
||||
| Entity | 实体引用 | null |
|
||||
| Vector2 | 二维向量 | (0, 0) |
|
||||
| Vector3 | 三维向量 | (0, 0, 0) |
|
||||
|
||||
### 变量节点错误状态
|
||||
|
||||
如果删除了一个变量,但画布上还有引用该变量的节点:
|
||||
- 节点会显示 **红色边框** 和 **警告图标**
|
||||
- 需要重新创建变量或删除这些节点
|
||||
|
||||
## 节点分组
|
||||
|
||||
可以将多个节点组织到一个可视化组框中,便于整理复杂蓝图。
|
||||
|
||||
### 创建组
|
||||
|
||||
1. 框选或 Ctrl+点击 选中多个节点(至少 2 个)
|
||||
2. 右键点击选中的节点
|
||||
3. 选择 **创建分组**
|
||||
4. 组框会自动包裹所有选中的节点
|
||||
|
||||
### 组操作
|
||||
|
||||
| 操作 | 方式 |
|
||||
|------|------|
|
||||
| 移动组 | 拖拽组框头部,所有节点一起移动 |
|
||||
| 取消分组 | 右键点击组框 → **取消分组** |
|
||||
|
||||
### 特性
|
||||
|
||||
- **动态大小**:组框会自动调整大小以包裹所有节点
|
||||
- **独立移动**:可以单独移动组内的节点,组框会自动调整
|
||||
- **仅编辑器**:组是纯视觉组织,不影响运行时逻辑
|
||||
|
||||
## 快捷键
|
||||
|
||||
| 快捷键 | 功能 |
|
||||
|--------|------|
|
||||
| `Ctrl + S` | 保存蓝图 |
|
||||
| `Ctrl + Z` | 撤销 |
|
||||
| `Ctrl + Shift + Z` | 重做 |
|
||||
| `Ctrl + C` | 复制选中节点 |
|
||||
| `Ctrl + X` | 剪切选中节点 |
|
||||
| `Ctrl + V` | 粘贴节点 |
|
||||
| `Delete` | 删除选中项 |
|
||||
| `Ctrl + A` | 全选 |
|
||||
|
||||
## 保存与加载
|
||||
|
||||
### 保存蓝图
|
||||
|
||||
1. 点击工具栏 **保存** 按钮
|
||||
2. 选择保存位置(**必须保存在 `assets/resources` 目录下**,否则 Cocos Creator 无法动态加载)
|
||||
3. 文件扩展名为 `.blueprint.json`
|
||||
|
||||
> **重要提示**:蓝图文件必须放在 `resources` 目录下,游戏运行时才能通过 `cc.resources.load()` 加载。
|
||||
|
||||
### 加载蓝图
|
||||
|
||||
1. 点击工具栏 **打开** 按钮
|
||||
2. 选择 `.blueprint.json` 文件
|
||||
|
||||
### 蓝图文件格式
|
||||
|
||||
蓝图保存为 JSON 格式,可与 `@esengine/blueprint` 运行时兼容:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"type": "blueprint",
|
||||
"metadata": {
|
||||
"name": "PlayerController",
|
||||
"description": "玩家控制逻辑"
|
||||
},
|
||||
"variables": [],
|
||||
"nodes": [],
|
||||
"connections": []
|
||||
}
|
||||
```
|
||||
|
||||
## 实战示例
|
||||
|
||||
### 示例 1:移动控制
|
||||
|
||||
实现每帧移动实体:
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"ex1-exec","to":"ex1-setprop","type":"exec"},{"from":"ex1-delta","to":"ex1-mul-a","type":"float"},{"from":"ex1-mul-result","to":"ex1-x","type":"float"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event Tick</span>
|
||||
<span class="bp-header-exec" data-pin="ex1-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="ex1-delta"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Delta Time</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 200px; top: 110px; width: 120px;">
|
||||
<div class="bp-node-header math">Multiply</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex1-mul-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">B (Speed)</span>
|
||||
<span class="bp-pin-value">100</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="ex1-mul-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 380px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Set Property</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex1-setprop"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7030c0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Target</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex1-x"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### 示例 2:生命值系统
|
||||
|
||||
受伤后检查死亡逻辑。`Event OnDamage` 是一个自定义事件节点,可以通过代码 `vm.triggerCustomEvent('OnDamage', { damage: 50 })` 触发:
|
||||
|
||||
<div class="bp-graph" data-graph='{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "event", "title": "Event OnDamage", "category": "event",
|
||||
"outputs": [
|
||||
{"id": "event-exec", "type": "exec", "inHeader": true},
|
||||
{"id": "event-self", "type": "entity", "label": "Self"},
|
||||
{"id": "event-damage", "type": "float", "label": "Damage"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "getcomp", "title": "Get Component", "category": "function",
|
||||
"inputs": [
|
||||
{"id": "getcomp-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "getcomp-entity", "type": "entity", "label": "Entity"},
|
||||
{"id": "getcomp-type", "type": "string", "label": "Type", "value": "Health", "connected": false}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "getcomp-out", "type": "exec"},
|
||||
{"id": "getcomp-comp", "type": "component", "label": "Component"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "getprop", "title": "Get Property", "category": "pure",
|
||||
"inputs": [
|
||||
{"id": "getprop-target", "type": "component", "label": "Target"},
|
||||
{"id": "getprop-prop", "type": "string", "label": "Property", "value": "current", "connected": false}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "getprop-val", "type": "float", "label": "Value"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sub", "title": "Subtract", "category": "math",
|
||||
"inputs": [
|
||||
{"id": "sub-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "sub-a", "type": "float", "label": "A"},
|
||||
{"id": "sub-b", "type": "float", "label": "B"}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "sub-out", "type": "exec"},
|
||||
{"id": "sub-result", "type": "float", "label": "Result"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "setprop", "title": "Set Property", "category": "function",
|
||||
"inputs": [
|
||||
{"id": "setprop-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "setprop-target", "type": "component", "label": "Target"},
|
||||
{"id": "setprop-prop", "type": "string", "label": "Property", "value": "current", "connected": false},
|
||||
{"id": "setprop-val", "type": "float", "label": "Value"}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "setprop-out", "type": "exec"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lte", "title": "Less Or Equal", "category": "pure",
|
||||
"inputs": [
|
||||
{"id": "lte-a", "type": "float", "label": "A"},
|
||||
{"id": "lte-b", "type": "float", "label": "B", "value": "0", "connected": false}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "lte-result", "type": "bool", "label": "Result"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "branch", "title": "Branch", "category": "flow",
|
||||
"inputs": [
|
||||
{"id": "branch-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "branch-cond", "type": "bool", "label": "Condition"}
|
||||
],
|
||||
"outputs": [
|
||||
{"id": "branch-true", "type": "exec", "label": "True"},
|
||||
{"id": "branch-false", "type": "exec", "label": "False"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "destroy", "title": "Destroy Entity", "category": "function",
|
||||
"inputs": [
|
||||
{"id": "destroy-exec", "type": "exec", "label": "Exec"},
|
||||
{"id": "destroy-entity", "type": "entity", "label": "Entity"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": [
|
||||
{"from": "event-exec", "to": "getcomp-exec", "type": "exec"},
|
||||
{"from": "getcomp-out", "to": "sub-exec", "type": "exec"},
|
||||
{"from": "sub-out", "to": "setprop-exec", "type": "exec"},
|
||||
{"from": "setprop-out", "to": "branch-exec", "type": "exec"},
|
||||
{"from": "branch-true", "to": "destroy-exec", "type": "exec"},
|
||||
{"from": "event-self", "to": "getcomp-entity", "type": "entity"},
|
||||
{"from": "event-self", "to": "destroy-entity", "type": "entity"},
|
||||
{"from": "getcomp-comp", "to": "getprop-target", "type": "component"},
|
||||
{"from": "getcomp-comp", "to": "setprop-target", "type": "component"},
|
||||
{"from": "getprop-val", "to": "sub-a", "type": "float"},
|
||||
{"from": "event-damage", "to": "sub-b", "type": "float"},
|
||||
{"from": "sub-result", "to": "setprop-val", "type": "float"},
|
||||
{"from": "sub-result", "to": "lte-a", "type": "float"},
|
||||
{"from": "lte-result", "to": "branch-cond", "type": "bool"}
|
||||
]
|
||||
}'></div>
|
||||
|
||||
### 示例 3:延迟生成
|
||||
|
||||
每 2 秒生成一个敌人:
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"ex3-begin-exec","to":"ex3-loop","type":"exec"},{"from":"ex3-loop-body","to":"ex3-delay","type":"exec"},{"from":"ex3-delay-done","to":"ex3-create","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="ex3-begin-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 240px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header flow">Do N Times</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex3-loop"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">N</span>
|
||||
<span class="bp-pin-value">10</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="ex3-loop-body"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Loop Body</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">Index</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 430px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header time">Delay</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex3-delay"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Duration</span>
|
||||
<span class="bp-pin-value">2.0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="ex3-delay-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 610px; top: 20px; width: 140px;">
|
||||
<div class="bp-node-header function">Create Entity</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="ex3-create"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Name</span>
|
||||
<span class="bp-pin-value">"Enemy"</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 节点无法连接?
|
||||
|
||||
检查引脚类型是否匹配。执行引脚(白色)只能连接执行引脚,数据引脚需要类型兼容。
|
||||
|
||||
### Q: 蓝图不执行?
|
||||
|
||||
1. 确保实体添加了 `BlueprintComponent`
|
||||
2. 确保场景添加了 `BlueprintSystem`
|
||||
3. 检查 `autoStart` 是否为 `true`
|
||||
|
||||
### Q: 如何调试?
|
||||
|
||||
使用 **Print** 节点输出变量值到控制台。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [ECS 节点参考](/modules/blueprint/nodes) - 完整节点列表
|
||||
- [自定义节点](/modules/blueprint/custom-nodes) - 创建自定义节点
|
||||
- [运行时集成](/modules/blueprint/vm) - 蓝图虚拟机 API
|
||||
- [实际示例](/modules/blueprint/examples) - 更多游戏逻辑示例
|
||||
@@ -3,85 +3,127 @@ title: "实际示例"
|
||||
description: "ECS 集成和最佳实践"
|
||||
---
|
||||
|
||||
## 玩家控制蓝图
|
||||
## 完整游戏集成示例
|
||||
|
||||
```typescript
|
||||
// 定义输入处理节点
|
||||
const InputMoveTemplate: BlueprintNodeTemplate = {
|
||||
type: 'InputMove',
|
||||
title: 'Get Movement Input',
|
||||
category: 'input',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'direction', type: 'vector2', direction: 'output' }
|
||||
],
|
||||
isPure: true
|
||||
};
|
||||
import { Scene, Core, Component, ECSComponent } from '@esengine/ecs-framework';
|
||||
import {
|
||||
BlueprintSystem,
|
||||
BlueprintComponent,
|
||||
BlueprintExpose,
|
||||
BlueprintProperty,
|
||||
BlueprintMethod
|
||||
} from '@esengine/blueprint';
|
||||
|
||||
@RegisterNode(InputMoveTemplate)
|
||||
class InputMoveExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
const input = context.scene.services.get(InputServiceToken);
|
||||
const direction = {
|
||||
x: input.getAxis('horizontal'),
|
||||
y: input.getAxis('vertical')
|
||||
};
|
||||
return { outputs: { direction } };
|
||||
// 1. 定义游戏组件
|
||||
@ECSComponent('Player')
|
||||
@BlueprintExpose({ displayName: '玩家', category: 'gameplay' })
|
||||
export class PlayerComponent extends Component {
|
||||
@BlueprintProperty({ displayName: '移动速度', type: 'float' })
|
||||
moveSpeed: number = 5;
|
||||
|
||||
@BlueprintProperty({ displayName: '分数', type: 'int' })
|
||||
score: number = 0;
|
||||
|
||||
@BlueprintMethod({ displayName: '增加分数' })
|
||||
addScore(points: number): void {
|
||||
this.score += points;
|
||||
}
|
||||
}
|
||||
|
||||
@ECSComponent('Health')
|
||||
@BlueprintExpose({ displayName: '生命值', category: 'gameplay' })
|
||||
export class HealthComponent extends Component {
|
||||
@BlueprintProperty({ displayName: '当前生命值' })
|
||||
current: number = 100;
|
||||
|
||||
@BlueprintProperty({ displayName: '最大生命值' })
|
||||
max: number = 100;
|
||||
|
||||
@BlueprintMethod({ displayName: '治疗' })
|
||||
heal(amount: number): void {
|
||||
this.current = Math.min(this.current + amount, this.max);
|
||||
}
|
||||
|
||||
@BlueprintMethod({ displayName: '受伤' })
|
||||
takeDamage(amount: number): boolean {
|
||||
this.current -= amount;
|
||||
return this.current <= 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 初始化游戏
|
||||
async function initGame() {
|
||||
const scene = new Scene();
|
||||
|
||||
// 添加蓝图系统
|
||||
scene.addSystem(new BlueprintSystem());
|
||||
|
||||
Core.setScene(scene);
|
||||
|
||||
// 3. 创建玩家
|
||||
const player = scene.createEntity('Player');
|
||||
player.addComponent(new PlayerComponent());
|
||||
player.addComponent(new HealthComponent());
|
||||
|
||||
// 添加蓝图控制
|
||||
const blueprint = new BlueprintComponent();
|
||||
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
|
||||
player.addComponent(blueprint);
|
||||
}
|
||||
```
|
||||
|
||||
## 状态切换逻辑
|
||||
## 自定义节点示例
|
||||
|
||||
```typescript
|
||||
// 在蓝图中实现状态机逻辑
|
||||
const stateBlueprint = createEmptyBlueprint('PlayerState');
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import {
|
||||
BlueprintNodeTemplate,
|
||||
BlueprintNode,
|
||||
ExecutionContext,
|
||||
ExecutionResult,
|
||||
INodeExecutor,
|
||||
RegisterNode
|
||||
} from '@esengine/blueprint';
|
||||
|
||||
// 添加状态变量
|
||||
stateBlueprint.variables.push({
|
||||
name: 'currentState',
|
||||
type: 'string',
|
||||
defaultValue: 'idle',
|
||||
scope: 'instance'
|
||||
});
|
||||
|
||||
// 在 Tick 事件中检查状态转换
|
||||
// ... 通过节点连接实现
|
||||
```
|
||||
|
||||
## 伤害处理系统
|
||||
|
||||
```typescript
|
||||
// 自定义伤害节点
|
||||
const ApplyDamageTemplate: BlueprintNodeTemplate = {
|
||||
type: 'ApplyDamage',
|
||||
title: 'Apply Damage',
|
||||
category: 'combat',
|
||||
color: '#aa2222',
|
||||
description: '对带有 Health 组件的实体造成伤害',
|
||||
keywords: ['damage', 'hurt', 'attack'],
|
||||
menuPath: ['Combat', 'Apply Damage'],
|
||||
inputs: [
|
||||
{ name: 'exec', type: 'exec', direction: 'input', isExec: true },
|
||||
{ name: 'target', type: 'entity', direction: 'input' },
|
||||
{ name: 'amount', type: 'number', direction: 'input', defaultValue: 10 }
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'target', type: 'entity', displayName: '目标' },
|
||||
{ name: 'amount', type: 'float', displayName: '伤害量', defaultValue: 10 }
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'exec', type: 'exec', direction: 'output', isExec: true },
|
||||
{ name: 'killed', type: 'boolean', direction: 'output' }
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'killed', type: 'bool', displayName: '已击杀' }
|
||||
]
|
||||
};
|
||||
|
||||
@RegisterNode(ApplyDamageTemplate)
|
||||
class ApplyDamageExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
const target = context.getInput<Entity>(node.id, 'target');
|
||||
const amount = context.getInput<number>(node.id, 'amount');
|
||||
const target = context.evaluateInput(node.id, 'target', context.entity) as Entity;
|
||||
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
|
||||
|
||||
if (!target || target.isDestroyed) {
|
||||
return { outputs: { killed: false }, nextExec: 'exec' };
|
||||
}
|
||||
|
||||
const health = target.components.find(c =>
|
||||
(c.constructor as any).__componentName__ === 'Health'
|
||||
) as any;
|
||||
|
||||
const health = target.getComponent(HealthComponent);
|
||||
if (health) {
|
||||
health.current -= amount;
|
||||
const killed = health.current <= 0;
|
||||
return {
|
||||
outputs: { killed },
|
||||
nextExec: 'exec'
|
||||
};
|
||||
return { outputs: { killed }, nextExec: 'exec' };
|
||||
}
|
||||
|
||||
return { outputs: { killed: false }, nextExec: 'exec' };
|
||||
@@ -89,25 +131,6 @@ class ApplyDamageExecutor implements INodeExecutor {
|
||||
}
|
||||
```
|
||||
|
||||
## 技能冷却系统
|
||||
|
||||
```typescript
|
||||
// 冷却检查节点
|
||||
const CheckCooldownTemplate: BlueprintNodeTemplate = {
|
||||
type: 'CheckCooldown',
|
||||
title: 'Check Cooldown',
|
||||
category: 'ability',
|
||||
inputs: [
|
||||
{ name: 'skillId', type: 'string', direction: 'input' }
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'ready', type: 'boolean', direction: 'output' },
|
||||
{ name: 'remaining', type: 'number', direction: 'output' }
|
||||
],
|
||||
isPure: true
|
||||
};
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 使用片段复用逻辑
|
||||
@@ -151,7 +174,8 @@ vm.maxStepsPerFrame = 1000;
|
||||
|
||||
```typescript
|
||||
// 启用调试模式查看执行日志
|
||||
vm.debug = true;
|
||||
const blueprint = entity.getComponent(BlueprintComponent);
|
||||
blueprint.debug = true;
|
||||
|
||||
// 使用 Print 节点输出中间值
|
||||
// 在编辑器中设置断点
|
||||
|
||||
@@ -1,114 +1,162 @@
|
||||
---
|
||||
title: "蓝图可视化脚本 (Blueprint)"
|
||||
description: "完整的可视化脚本系统"
|
||||
description: "与 ECS 框架深度集成的可视化脚本系统"
|
||||
---
|
||||
|
||||
`@esengine/blueprint` 提供了一个功能完整的可视化脚本系统,支持节点式编程、事件驱动和蓝图组合。
|
||||
`@esengine/blueprint` 提供与 ECS 框架深度集成的可视化脚本系统,支持通过节点式编程控制实体行为。
|
||||
|
||||
## 安装
|
||||
## 编辑器下载
|
||||
|
||||
Cocos Creator 蓝图编辑器插件(免费):
|
||||
|
||||
**[下载 Cocos Node Editor v1.1.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.1.0)**
|
||||
|
||||
> 技术交流 QQ 群:**481923584** | 官网:[esengine.cn](https://esengine.cn/)
|
||||
|
||||
详细使用教程请参考 [编辑器使用指南](./editor-guide)。
|
||||
|
||||
## 安装运行时
|
||||
|
||||
```bash
|
||||
npm install @esengine/blueprint
|
||||
```
|
||||
|
||||
## 核心特性
|
||||
|
||||
- **ECS 深度集成** - 内置 Entity、Component 操作节点
|
||||
- **组件自动节点生成** - 使用装饰器标记组件,自动生成 Get/Set/Call 节点
|
||||
- **运行时蓝图执行** - 高效的虚拟机执行蓝图逻辑
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 添加蓝图系统
|
||||
|
||||
```typescript
|
||||
import { Scene, Core } from '@esengine/ecs-framework';
|
||||
import { BlueprintSystem } from '@esengine/blueprint';
|
||||
|
||||
// 创建场景并添加蓝图系统
|
||||
const scene = new Scene();
|
||||
scene.addSystem(new BlueprintSystem());
|
||||
|
||||
// 设置场景
|
||||
Core.setScene(scene);
|
||||
```
|
||||
|
||||
### 2. 为实体添加蓝图
|
||||
|
||||
```typescript
|
||||
import { BlueprintComponent } from '@esengine/blueprint';
|
||||
|
||||
// 创建实体
|
||||
const player = scene.createEntity('Player');
|
||||
|
||||
// 添加蓝图组件
|
||||
const blueprint = new BlueprintComponent();
|
||||
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
|
||||
blueprint.autoStart = true;
|
||||
player.addComponent(blueprint);
|
||||
```
|
||||
|
||||
### 3. 标记组件(自动生成蓝图节点)
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createBlueprintSystem,
|
||||
createBlueprintComponentData,
|
||||
NodeRegistry,
|
||||
RegisterNode
|
||||
BlueprintExpose,
|
||||
BlueprintProperty,
|
||||
BlueprintMethod
|
||||
} from '@esengine/blueprint';
|
||||
import { Component, ECSComponent } from '@esengine/ecs-framework';
|
||||
|
||||
// 创建蓝图系统
|
||||
const blueprintSystem = createBlueprintSystem(scene);
|
||||
@ECSComponent('Health')
|
||||
@BlueprintExpose({ displayName: '生命值', category: 'gameplay' })
|
||||
export class HealthComponent extends Component {
|
||||
@BlueprintProperty({ displayName: '当前生命值', type: 'float' })
|
||||
current: number = 100;
|
||||
|
||||
// 加载蓝图资产
|
||||
const blueprint = await loadBlueprintAsset('player.bp');
|
||||
@BlueprintProperty({ displayName: '最大生命值', type: 'float' })
|
||||
max: number = 100;
|
||||
|
||||
// 创建蓝图组件数据
|
||||
const componentData = createBlueprintComponentData();
|
||||
componentData.blueprintAsset = blueprint;
|
||||
@BlueprintMethod({
|
||||
displayName: '治疗',
|
||||
params: [{ name: 'amount', type: 'float' }]
|
||||
})
|
||||
heal(amount: number): void {
|
||||
this.current = Math.min(this.current + amount, this.max);
|
||||
}
|
||||
|
||||
// 在游戏循环中更新
|
||||
function gameLoop(dt: number) {
|
||||
blueprintSystem.process(entities, dt);
|
||||
@BlueprintMethod({ displayName: '受伤' })
|
||||
takeDamage(amount: number): boolean {
|
||||
this.current -= amount;
|
||||
return this.current <= 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 核心概念
|
||||
标记后,蓝图编辑器中会自动出现以下节点:
|
||||
- **Get Health** - 获取 Health 组件
|
||||
- **Get 当前生命值** - 获取 current 属性
|
||||
- **Set 当前生命值** - 设置 current 属性
|
||||
- **治疗** - 调用 heal 方法
|
||||
- **受伤** - 调用 takeDamage 方法
|
||||
|
||||
### 蓝图资产结构
|
||||
## ECS 集成架构
|
||||
|
||||
蓝图保存为 `.bp` 文件,包含以下结构:
|
||||
|
||||
```typescript
|
||||
interface BlueprintAsset {
|
||||
version: number; // 格式版本
|
||||
type: 'blueprint'; // 资产类型
|
||||
metadata: BlueprintMetadata; // 元数据
|
||||
variables: BlueprintVariable[]; // 变量定义
|
||||
nodes: BlueprintNode[]; // 节点实例
|
||||
connections: BlueprintConnection[]; // 连接
|
||||
}
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Core.update() │
|
||||
│ ↓ │
|
||||
│ Scene.updateSystems() │
|
||||
│ ↓ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ BlueprintSystem │ │
|
||||
│ │ │ │
|
||||
│ │ Matcher.all(BlueprintComponent) │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ process(entities) → blueprint.tick() for each entity │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ BlueprintVM.tick(dt) │ │
|
||||
│ │ ↓ │ │
|
||||
│ │ Execute Event/ECS/Flow Nodes │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 节点类型
|
||||
|
||||
节点按功能分为以下类别:
|
||||
## 节点类型
|
||||
|
||||
| 类别 | 说明 | 颜色 |
|
||||
|------|------|------|
|
||||
| `event` | 事件节点(入口点) | 红色 |
|
||||
| `flow` | 流程控制 | 灰色 |
|
||||
| `entity` | 实体操作 | 蓝色 |
|
||||
| `component` | 组件访问 | 青色 |
|
||||
| `event` | 事件节点(BeginPlay, Tick, EndPlay) | 红色 |
|
||||
| `entity` | ECS 实体操作 | 蓝色 |
|
||||
| `component` | ECS 组件访问 | 青色 |
|
||||
| `flow` | 流程控制(Branch, Sequence, Loop) | 灰色 |
|
||||
| `math` | 数学运算 | 绿色 |
|
||||
| `logic` | 逻辑运算 | 红色 |
|
||||
| `variable` | 变量访问 | 紫色 |
|
||||
| `time` | 时间工具 | 青色 |
|
||||
| `debug` | 调试工具 | 灰色 |
|
||||
| `time` | 时间工具(Delay, GetDeltaTime) | 青色 |
|
||||
| `debug` | 调试工具(Print) | 灰色 |
|
||||
|
||||
### 引脚类型
|
||||
## 蓝图资产结构
|
||||
|
||||
节点通过引脚连接:
|
||||
蓝图保存为 `.bp` 文件:
|
||||
|
||||
```typescript
|
||||
interface BlueprintPinDefinition {
|
||||
name: string; // 引脚名称
|
||||
type: PinDataType; // 数据类型
|
||||
direction: 'input' | 'output';
|
||||
isExec?: boolean; // 是否是执行引脚
|
||||
defaultValue?: unknown;
|
||||
interface BlueprintAsset {
|
||||
version: number;
|
||||
type: 'blueprint';
|
||||
metadata: {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
variables: BlueprintVariable[];
|
||||
nodes: BlueprintNode[];
|
||||
connections: BlueprintConnection[];
|
||||
}
|
||||
|
||||
// 支持的数据类型
|
||||
type PinDataType =
|
||||
| 'exec' // 执行流
|
||||
| 'boolean' // 布尔值
|
||||
| 'number' // 数字
|
||||
| 'string' // 字符串
|
||||
| 'vector2' // 2D 向量
|
||||
| 'vector3' // 3D 向量
|
||||
| 'entity' // 实体引用
|
||||
| 'component' // 组件引用
|
||||
| 'any'; // 任意类型
|
||||
```
|
||||
|
||||
### 变量作用域
|
||||
|
||||
```typescript
|
||||
type VariableScope =
|
||||
| 'local' // 每次执行独立
|
||||
| 'instance' // 每个实体独立
|
||||
| 'global'; // 全局共享
|
||||
```
|
||||
|
||||
## 文档导航
|
||||
|
||||
- [虚拟机 API](./vm) - BlueprintVM 执行和上下文
|
||||
- [自定义节点](./custom-nodes) - 创建自定义节点
|
||||
- [内置节点](./nodes) - 内置节点参考
|
||||
- [蓝图组合](./composition) - 片段和组合器
|
||||
- [实际示例](./examples) - ECS 集成和最佳实践
|
||||
- [编辑器使用指南](./editor-guide) - Cocos Creator 蓝图编辑器教程
|
||||
- [虚拟机 API](./vm) - BlueprintVM 与 ECS 集成
|
||||
- [ECS 节点参考](./nodes) - 内置 ECS 操作节点
|
||||
- [自定义节点](./custom-nodes) - 创建自定义 ECS 节点
|
||||
- [蓝图组合](./composition) - 片段复用
|
||||
- [实际示例](./examples) - ECS 游戏逻辑示例
|
||||
|
||||
@@ -1,107 +1,540 @@
|
||||
---
|
||||
title: "内置节点"
|
||||
description: "蓝图内置节点参考"
|
||||
title: "ECS 节点参考"
|
||||
description: "蓝图内置 ECS 操作节点完整参考"
|
||||
---
|
||||
|
||||
本文档提供蓝图系统所有内置节点的完整参考,包含可视化示例。
|
||||
|
||||
## 引脚类型说明
|
||||
|
||||
<div class="bp-legend">
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff" stroke="#fff" stroke-width="1"/></svg> 执行流 (Exec)</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#00a0e0" stroke-width="2"/></svg> 实体 (Entity)</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#7030c0" stroke-width="2"/></svg> 组件 (Component)</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#7ecd32" stroke-width="2"/></svg> 数值 (Float)</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#e060e0" stroke-width="2"/></svg> 字符串 (String)</div>
|
||||
<div class="bp-legend-item"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="transparent" stroke="#8c0000" stroke-width="2"/></svg> 布尔 (Boolean)</div>
|
||||
</div>
|
||||
|
||||
## 事件节点
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `EventBeginPlay` | 蓝图启动时触发 |
|
||||
| `EventTick` | 每帧触发 |
|
||||
| `EventEndPlay` | 蓝图停止时触发 |
|
||||
| `EventCollision` | 碰撞时触发 |
|
||||
| `EventInput` | 输入事件触发 |
|
||||
| `EventTimer` | 定时器触发 |
|
||||
| `EventMessage` | 自定义消息触发 |
|
||||
生命周期事件,作为蓝图执行的入口点:
|
||||
|
||||
| 节点 | 说明 | 输出 |
|
||||
|------|------|------|
|
||||
| `EventBeginPlay` | 蓝图启动时触发 | Exec, Self (Entity) |
|
||||
| `EventTick` | 每帧触发 | Exec, Delta Time |
|
||||
| `EventEndPlay` | 蓝图停止时触发 | Exec |
|
||||
|
||||
### 示例:游戏初始化
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"beginplay-exec","to":"print-exec","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="beginplay-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Self</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="print-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Message</span>
|
||||
<span class="bp-pin-value">"游戏开始!"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### 示例:每帧移动
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"tick-exec","to":"setprop-exec","type":"exec"},{"from":"tick-delta","to":"mul-a","type":"float"},{"from":"mul-result","to":"setprop-x","type":"float"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 140px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event Tick</span>
|
||||
<span class="bp-header-exec" data-pin="tick-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="tick-delta"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Delta Time</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 200px; top: 110px; width: 120px;">
|
||||
<div class="bp-node-header math">Multiply</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="mul-a"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">A</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">B</span>
|
||||
<span class="bp-pin-value">100</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="mul-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 380px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Set Property</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="setprop-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7030c0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Target</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="setprop-x"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 实体节点 (Entity)
|
||||
|
||||
操作 ECS 实体:
|
||||
|
||||
| 节点 | 说明 | 类型 |
|
||||
|------|------|------|
|
||||
| `Get Self` | 获取拥有此蓝图的实体 | 纯节点 |
|
||||
| `Create Entity` | 在场景中创建新实体 | 执行节点 |
|
||||
| `Destroy Entity` | 销毁指定实体 | 执行节点 |
|
||||
| `Destroy Self` | 销毁自身实体 | 执行节点 |
|
||||
| `Is Valid` | 检查实体是否有效 | 纯节点 |
|
||||
| `Get Entity Name` | 获取实体名称 | 纯节点 |
|
||||
| `Set Entity Name` | 设置实体名称 | 执行节点 |
|
||||
| `Find Entity By Name` | 按名称查找实体 | 纯节点 |
|
||||
| `Find Entities By Tag` | 按标签查找所有实体 | 纯节点 |
|
||||
|
||||
### 示例:创建子弹
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"bp-exec","to":"create-exec","type":"exec"},{"from":"create-exec-out","to":"add-exec","type":"exec"},{"from":"create-entity","to":"add-entity","type":"entity"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Self</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Create Entity</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="create-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="create-exec-out"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="create-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 520px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header function">Add Transform</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="add-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="add-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 组件节点 (Component)
|
||||
|
||||
读写组件属性:
|
||||
|
||||
| 节点 | 说明 | 类型 |
|
||||
|------|------|------|
|
||||
| `Get Component` | 从实体获取指定类型组件 | 纯节点 |
|
||||
| `Has Component` | 检查实体是否拥有指定组件 | 纯节点 |
|
||||
| `Add Component` | 为实体添加组件 | 执行节点 |
|
||||
| `Remove Component` | 从实体移除组件 | 执行节点 |
|
||||
| `Get Property` | 获取组件属性值 | 纯节点 |
|
||||
| `Set Property` | 设置组件属性值 | 执行节点 |
|
||||
|
||||
### 示例:修改位置
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"self-entity","to":"getcomp-entity","type":"entity"},{"from":"getcomp-transform","to":"getprop-target","type":"component"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 100px;">
|
||||
<div class="bp-node-header pure">Get Self</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="self-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 200px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header pure">Get Component</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="getcomp-entity"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#00a0e0"/></svg></span>
|
||||
<span class="bp-pin-label">Entity</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="getcomp-transform"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
|
||||
<span class="bp-pin-label">Transform</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 430px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header pure">Get Property</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="getprop-target"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7030c0"/></svg></span>
|
||||
<span class="bp-pin-label">Target</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 流程控制节点
|
||||
|
||||
控制蓝图执行流程:
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `Branch` | 条件分支 (if/else) |
|
||||
| `Sequence` | 顺序执行多个输出 |
|
||||
| `ForLoop` | 循环执行 |
|
||||
| `WhileLoop` | 条件循环 |
|
||||
| `DoOnce` | 只执行一次 |
|
||||
| `FlipFlop` | 交替执行两个分支 |
|
||||
| `Gate` | 可开关的执行门 |
|
||||
| `Branch` | 条件分支(if/else) |
|
||||
| `Sequence` | 按顺序执行多个分支 |
|
||||
| `For Loop` | 指定次数循环 |
|
||||
| `For Each` | 遍历数组元素 |
|
||||
| `While Loop` | 条件循环 |
|
||||
| `Do Once` | 仅执行一次 |
|
||||
| `Flip Flop` | 交替执行 A/B |
|
||||
| `Gate` | 门控开关 |
|
||||
|
||||
### 示例:条件分支
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"cond-exec","to":"branch-exec","type":"exec"},{"from":"cond-result","to":"branch-cond","type":"bool"},{"from":"branch-true","to":"print1-exec","type":"exec"},{"from":"branch-false","to":"print2-exec","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 60px; width: 120px;">
|
||||
<div class="bp-node-header pure">Condition</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="cond-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="cond-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#8c0000"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 220px; top: 60px; width: 110px;">
|
||||
<div class="bp-node-header flow">Branch</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="branch-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="branch-cond"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#8c0000"/></svg></span>
|
||||
<span class="bp-pin-label">Cond</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="branch-true"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">True</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="branch-false"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">False</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 420px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="print1-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Msg</span>
|
||||
<span class="bp-pin-value">"是"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 420px; top: 130px; width: 120px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="print2-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Msg</span>
|
||||
<span class="bp-pin-value">"否"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### 示例:For 循环
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"forloop-bp-exec","to":"forloop-exec","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="forloop-bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 150px;">
|
||||
<div class="bp-node-header flow">For Loop</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="forloop-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">First</span>
|
||||
<span class="bp-pin-value">0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#1cc4c4" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Last</span>
|
||||
<span class="bp-pin-value">10</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Body</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#1cc4c4"/></svg></span>
|
||||
<span class="bp-pin-label">Index</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 时间节点
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `Delay` | 延迟执行 |
|
||||
| `GetDeltaTime` | 获取帧间隔 |
|
||||
| `GetTime` | 获取运行时间 |
|
||||
| `SetTimer` | 设置定时器 |
|
||||
| `ClearTimer` | 清除定时器 |
|
||||
| 节点 | 说明 | 输出 |
|
||||
|------|------|------|
|
||||
| `Delay` | 延迟执行指定秒数 | Exec |
|
||||
| `Get Delta Time` | 获取帧间隔时间 | Float |
|
||||
| `Get Time` | 获取运行总时间 | Float |
|
||||
|
||||
### 示例:延迟执行
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"delay-bp-exec","to":"delay-exec","type":"exec"},{"from":"delay-done","to":"delay-print-exec","type":"exec"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 170px;">
|
||||
<div class="bp-node-header event">
|
||||
<span class="bp-node-header-icon"></span>
|
||||
<span class="bp-node-header-title">Event BeginPlay</span>
|
||||
<span class="bp-header-exec" data-pin="delay-bp-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 280px; top: 20px; width: 120px;">
|
||||
<div class="bp-node-header time">Delay</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="delay-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Duration</span>
|
||||
<span class="bp-pin-value">2.0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="delay-done"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 490px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header debug">Print</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="delay-print-exec"><svg width="12" height="12"><polygon points="1,1 11,6 1,11" fill="#fff"/></svg></span>
|
||||
<span class="bp-pin-label">Exec</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#e060e0" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Msg</span>
|
||||
<span class="bp-pin-value">"2秒后"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 数学节点
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `Add` | 加法 |
|
||||
| `Subtract` | 减法 |
|
||||
| `Multiply` | 乘法 |
|
||||
| `Divide` | 除法 |
|
||||
| `Abs` | 绝对值 |
|
||||
| `Clamp` | 限制范围 |
|
||||
| `Lerp` | 线性插值 |
|
||||
| `Min` / `Max` | 最小/最大值 |
|
||||
| `Sin` / `Cos` | 三角函数 |
|
||||
| `Sqrt` | 平方根 |
|
||||
| `Power` | 幂运算 |
|
||||
### 基础运算
|
||||
|
||||
## 逻辑节点
|
||||
| 节点 | 说明 | 输入 | 输出 |
|
||||
|------|------|------|------|
|
||||
| `Add` | 加法 | A, B | A + B |
|
||||
| `Subtract` | 减法 | A, B | A - B |
|
||||
| `Multiply` | 乘法 | A, B | A × B |
|
||||
| `Divide` | 除法 | A, B | A / B |
|
||||
| `Modulo` | 取模 | A, B | A % B |
|
||||
|
||||
### 数学函数
|
||||
|
||||
| 节点 | 说明 | 输入 | 输出 |
|
||||
|------|------|------|------|
|
||||
| `Abs` | 绝对值 | Value | \|Value\| |
|
||||
| `Sqrt` | 平方根 | Value | √Value |
|
||||
| `Pow` | 幂运算 | Base, Exp | Base^Exp |
|
||||
| `Floor` | 向下取整 | Value | ⌊Value⌋ |
|
||||
| `Ceil` | 向上取整 | Value | ⌈Value⌉ |
|
||||
| `Round` | 四舍五入 | Value | round(Value) |
|
||||
| `Clamp` | 区间钳制 | Value, Min, Max | min(max(V, Min), Max) |
|
||||
| `Lerp` | 线性插值 | A, B, Alpha | A + (B-A) × Alpha |
|
||||
| `Min` | 取最小值 | A, B | min(A, B) |
|
||||
| `Max` | 取最大值 | A, B | max(A, B) |
|
||||
|
||||
### 三角函数
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `And` | 逻辑与 |
|
||||
| `Or` | 逻辑或 |
|
||||
| `Not` | 逻辑非 |
|
||||
| `Equal` | 相等比较 |
|
||||
| `NotEqual` | 不等比较 |
|
||||
| `Greater` | 大于比较 |
|
||||
| `Less` | 小于比较 |
|
||||
| `Sin` | 正弦 |
|
||||
| `Cos` | 余弦 |
|
||||
| `Tan` | 正切 |
|
||||
| `Asin` | 反正弦 |
|
||||
| `Acos` | 反余弦 |
|
||||
| `Atan` | 反正切 |
|
||||
| `Atan2` | 二参数反正切 |
|
||||
|
||||
## 向量节点
|
||||
### 随机数
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `MakeVector2` | 创建 2D 向量 |
|
||||
| `BreakVector2` | 分解 2D 向量 |
|
||||
| `VectorAdd` | 向量加法 |
|
||||
| `VectorSubtract` | 向量减法 |
|
||||
| `VectorMultiply` | 向量乘法 |
|
||||
| `VectorLength` | 向量长度 |
|
||||
| `VectorNormalize` | 向量归一化 |
|
||||
| `VectorDistance` | 向量距离 |
|
||||
| 节点 | 说明 | 输入 | 输出 |
|
||||
|------|------|------|------|
|
||||
| `Random` | 随机浮点数 [0, 1) | - | Float |
|
||||
| `Random Range` | 范围内随机数 | Min, Max | Float |
|
||||
| `Random Int` | 随机整数 | Min, Max | Int |
|
||||
|
||||
## 实体节点
|
||||
### 比较节点
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `GetSelf` | 获取当前实体 |
|
||||
| `GetComponent` | 获取组件 |
|
||||
| `HasComponent` | 检查组件 |
|
||||
| `AddComponent` | 添加组件 |
|
||||
| `RemoveComponent` | 移除组件 |
|
||||
| `SpawnEntity` | 创建实体 |
|
||||
| `DestroyEntity` | 销毁实体 |
|
||||
| 节点 | 说明 | 输出 |
|
||||
|------|------|------|
|
||||
| `Equal` | A == B | Boolean |
|
||||
| `Not Equal` | A != B | Boolean |
|
||||
| `Greater` | A > B | Boolean |
|
||||
| `Greater Or Equal` | A >= B | Boolean |
|
||||
| `Less` | A < B | Boolean |
|
||||
| `Less Or Equal` | A <= B | Boolean |
|
||||
|
||||
### 示例:钳制数值
|
||||
|
||||
<div class="bp-graph" style="" data-connections='[{"from":"rand-result","to":"clamp-value","type":"float"}]'>
|
||||
<svg class="bp-connections"></svg>
|
||||
<div class="bp-node" style="left: 20px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header math">Random Range</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Min</span>
|
||||
<span class="bp-pin-value">0</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Max</span>
|
||||
<span class="bp-pin-value">100</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin" data-pin="rand-result"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-node" style="left: 240px; top: 20px; width: 130px;">
|
||||
<div class="bp-node-header math">Clamp</div>
|
||||
<div class="bp-node-body">
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin" data-pin="clamp-value"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Value</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Min</span>
|
||||
<span class="bp-pin-value">20</span>
|
||||
</div>
|
||||
<div class="bp-pin-row input">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="none" stroke="#7ecd32" stroke-width="2"/></svg></span>
|
||||
<span class="bp-pin-label">Max</span>
|
||||
<span class="bp-pin-value">80</span>
|
||||
</div>
|
||||
<div class="bp-pin-row output">
|
||||
<span class="bp-pin"><svg width="12" height="12"><circle cx="6" cy="6" r="4" fill="#7ecd32"/></svg></span>
|
||||
<span class="bp-pin-label">Result</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 变量节点
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `GetVariable` | 获取变量值 |
|
||||
| `SetVariable` | 设置变量值 |
|
||||
蓝图定义的变量会自动生成 Get 和 Set 节点:
|
||||
|
||||
| 节点 | 说明 | 类型 |
|
||||
|------|------|------|
|
||||
| `Get <变量名>` | 读取变量值 | 纯节点 |
|
||||
| `Set <变量名>` | 设置变量值 | 执行节点 |
|
||||
|
||||
## 调试节点
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `Print` | 打印到控制台 |
|
||||
| `DrawDebugLine` | 绘制调试线 |
|
||||
| `DrawDebugPoint` | 绘制调试点 |
|
||||
| `Breakpoint` | 调试断点 |
|
||||
| `Print` | 输出消息到控制台 |
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [蓝图编辑器指南](/modules/blueprint/editor-guide) - 学习如何使用编辑器
|
||||
- [自定义节点](/modules/blueprint/custom-nodes) - 创建自定义节点
|
||||
- [蓝图虚拟机](/modules/blueprint/vm) - 运行时 API
|
||||
|
||||
@@ -45,7 +45,7 @@ interface ExecutionContext {
|
||||
time: number; // 总运行时间
|
||||
|
||||
// 获取输入值
|
||||
getInput<T>(nodeId: string, pinName: string): T;
|
||||
evaluateInput(nodeId: string, pinName: string, defaultValue: unknown): unknown;
|
||||
|
||||
// 设置输出值
|
||||
setOutput(nodeId: string, pinName: string, value: unknown): void;
|
||||
@@ -70,35 +70,33 @@ interface ExecutionResult {
|
||||
|
||||
## 与 ECS 集成
|
||||
|
||||
### 使用蓝图系统
|
||||
### 使用内置蓝图系统
|
||||
|
||||
```typescript
|
||||
import { createBlueprintSystem } from '@esengine/blueprint';
|
||||
import { Scene, Core } from '@esengine/ecs-framework';
|
||||
import { BlueprintSystem, BlueprintComponent } from '@esengine/blueprint';
|
||||
|
||||
class GameScene {
|
||||
private blueprintSystem: BlueprintSystem;
|
||||
// 添加蓝图系统到场景
|
||||
const scene = new Scene();
|
||||
scene.addSystem(new BlueprintSystem());
|
||||
Core.setScene(scene);
|
||||
|
||||
initialize() {
|
||||
this.blueprintSystem = createBlueprintSystem(this.scene);
|
||||
}
|
||||
|
||||
update(dt: number) {
|
||||
// 处理所有带蓝图组件的实体
|
||||
this.blueprintSystem.process(this.entities, dt);
|
||||
}
|
||||
}
|
||||
// 为实体添加蓝图
|
||||
const entity = scene.createEntity('Player');
|
||||
const blueprint = new BlueprintComponent();
|
||||
blueprint.blueprintAsset = await loadBlueprintAsset('player.bp');
|
||||
entity.addComponent(blueprint);
|
||||
```
|
||||
|
||||
### 触发蓝图事件
|
||||
|
||||
```typescript
|
||||
import { triggerBlueprintEvent, triggerCustomBlueprintEvent } from '@esengine/blueprint';
|
||||
|
||||
// 触发内置事件
|
||||
triggerBlueprintEvent(entity, 'Collision', { other: otherEntity });
|
||||
|
||||
// 触发自定义事件
|
||||
triggerCustomBlueprintEvent(entity, 'OnPickup', { item: itemEntity });
|
||||
// 从实体获取蓝图组件并触发事件
|
||||
const blueprint = entity.getComponent(BlueprintComponent);
|
||||
if (blueprint?.vm) {
|
||||
blueprint.vm.triggerEvent('EventCollision', { other: otherEntity });
|
||||
blueprint.vm.triggerCustomEvent('OnPickup', { item: itemEntity });
|
||||
}
|
||||
```
|
||||
|
||||
## 序列化
|
||||
|
||||
136
docs/src/content/docs/modules/database-drivers/index.md
Normal file
136
docs/src/content/docs/modules/database-drivers/index.md
Normal file
@@ -0,0 +1,136 @@
|
||||
---
|
||||
title: "数据库驱动"
|
||||
description: "MongoDB、Redis 等数据库的连接管理和驱动封装"
|
||||
---
|
||||
|
||||
`@esengine/database-drivers` 是 ESEngine 的数据库连接管理层,提供 MongoDB、Redis 等数据库的统一连接管理。
|
||||
|
||||
## 特性
|
||||
|
||||
- **连接池管理** - 自动管理连接池,优化资源使用
|
||||
- **自动重连** - 连接断开时自动重连
|
||||
- **事件通知** - 连接状态变化事件
|
||||
- **类型解耦** - 简化接口,不依赖原生驱动类型
|
||||
- **共享连接** - 单一连接可供多个模块共享
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install @esengine/database-drivers
|
||||
```
|
||||
|
||||
**对等依赖:**
|
||||
```bash
|
||||
npm install mongodb # MongoDB 支持
|
||||
npm install ioredis # Redis 支持
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ @esengine/database-drivers (Layer 1) │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ MongoConnection │ │ RedisConnection │ │
|
||||
│ │ - 连接池管理 │ │ - 自动重连 │ │
|
||||
│ │ - 自动重连 │ │ - Key 前缀 │ │
|
||||
│ │ - 事件发射器 │ │ - 事件发射器 │ │
|
||||
│ └──────────┬──────────┘ └─────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ IMongoCollection<T> │ ← 类型安全接口 │
|
||||
│ │ (适配器模式) │ 与 mongodb 类型解耦 │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐ ┌───────────────────────┐
|
||||
│ @esengine/database │ │ @esengine/transaction │
|
||||
│ (仓库模式) │ │ (分布式事务) │
|
||||
└───────────────────────┘ └───────────────────────┘
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### MongoDB 连接
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
|
||||
// 创建连接
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game',
|
||||
pool: {
|
||||
minSize: 5,
|
||||
maxSize: 20
|
||||
},
|
||||
autoReconnect: true
|
||||
})
|
||||
|
||||
// 监听事件
|
||||
mongo.on('connected', () => console.log('MongoDB 已连接'))
|
||||
mongo.on('disconnected', () => console.log('MongoDB 已断开'))
|
||||
mongo.on('error', (e) => console.error('错误:', e.error))
|
||||
|
||||
// 建立连接
|
||||
await mongo.connect()
|
||||
|
||||
// 使用集合
|
||||
const users = mongo.collection<User>('users')
|
||||
await users.insertOne({ name: 'John', score: 100 })
|
||||
|
||||
const user = await users.findOne({ name: 'John' })
|
||||
|
||||
// 完成后断开连接
|
||||
await mongo.disconnect()
|
||||
```
|
||||
|
||||
### Redis 连接
|
||||
|
||||
```typescript
|
||||
import { createRedisConnection } from '@esengine/database-drivers'
|
||||
|
||||
const redis = createRedisConnection({
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
keyPrefix: 'game:',
|
||||
autoReconnect: true
|
||||
})
|
||||
|
||||
await redis.connect()
|
||||
|
||||
// 基本操作
|
||||
await redis.set('session:123', 'data', 3600) // 带 TTL
|
||||
const value = await redis.get('session:123')
|
||||
|
||||
await redis.disconnect()
|
||||
```
|
||||
|
||||
## 服务容器集成
|
||||
|
||||
```typescript
|
||||
import { ServiceContainer } from '@esengine/ecs-framework'
|
||||
import {
|
||||
createMongoConnection,
|
||||
MongoConnectionToken,
|
||||
RedisConnectionToken
|
||||
} from '@esengine/database-drivers'
|
||||
|
||||
const services = new ServiceContainer()
|
||||
|
||||
// 注册连接
|
||||
const mongo = createMongoConnection({ uri: '...', database: 'game' })
|
||||
await mongo.connect()
|
||||
services.register(MongoConnectionToken, mongo)
|
||||
|
||||
// 在其他模块中获取
|
||||
const connection = services.get(MongoConnectionToken)
|
||||
const users = connection.collection('users')
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
- [MongoDB 连接](/modules/database-drivers/mongo/) - MongoDB 连接详细配置
|
||||
- [Redis 连接](/modules/database-drivers/redis/) - Redis 连接详细配置
|
||||
- [服务令牌](/modules/database-drivers/tokens/) - 依赖注入集成
|
||||
265
docs/src/content/docs/modules/database-drivers/mongo.md
Normal file
265
docs/src/content/docs/modules/database-drivers/mongo.md
Normal file
@@ -0,0 +1,265 @@
|
||||
---
|
||||
title: "MongoDB 连接"
|
||||
description: "MongoDB 连接管理、连接池、自动重连"
|
||||
---
|
||||
|
||||
## 配置选项
|
||||
|
||||
```typescript
|
||||
interface MongoConnectionConfig {
|
||||
/** MongoDB 连接 URI */
|
||||
uri: string
|
||||
|
||||
/** 数据库名称 */
|
||||
database: string
|
||||
|
||||
/** 连接池配置 */
|
||||
pool?: {
|
||||
minSize?: number // 最小连接数
|
||||
maxSize?: number // 最大连接数
|
||||
acquireTimeout?: number // 获取连接超时(毫秒)
|
||||
maxLifetime?: number // 连接最大生命周期(毫秒)
|
||||
}
|
||||
|
||||
/** 是否自动重连(默认 true) */
|
||||
autoReconnect?: boolean
|
||||
|
||||
/** 重连间隔(毫秒,默认 5000) */
|
||||
reconnectInterval?: number
|
||||
|
||||
/** 最大重连次数(默认 10) */
|
||||
maxReconnectAttempts?: number
|
||||
}
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection, MongoConnectionToken } from '@esengine/database-drivers'
|
||||
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game',
|
||||
pool: {
|
||||
minSize: 5,
|
||||
maxSize: 20,
|
||||
acquireTimeout: 5000,
|
||||
maxLifetime: 300000
|
||||
},
|
||||
autoReconnect: true,
|
||||
reconnectInterval: 5000,
|
||||
maxReconnectAttempts: 10
|
||||
})
|
||||
|
||||
// 事件监听
|
||||
mongo.on('connected', () => {
|
||||
console.log('MongoDB 已连接')
|
||||
})
|
||||
|
||||
mongo.on('disconnected', () => {
|
||||
console.log('MongoDB 已断开')
|
||||
})
|
||||
|
||||
mongo.on('reconnecting', () => {
|
||||
console.log('MongoDB 正在重连...')
|
||||
})
|
||||
|
||||
mongo.on('reconnected', () => {
|
||||
console.log('MongoDB 重连成功')
|
||||
})
|
||||
|
||||
mongo.on('error', (event) => {
|
||||
console.error('MongoDB 错误:', event.error)
|
||||
})
|
||||
|
||||
// 连接
|
||||
await mongo.connect()
|
||||
|
||||
// 检查状态
|
||||
console.log('已连接:', mongo.isConnected())
|
||||
console.log('Ping:', await mongo.ping())
|
||||
```
|
||||
|
||||
## IMongoConnection 接口
|
||||
|
||||
```typescript
|
||||
interface IMongoConnection {
|
||||
/** 连接 ID */
|
||||
readonly id: string
|
||||
|
||||
/** 连接状态 */
|
||||
readonly state: ConnectionState
|
||||
|
||||
/** 建立连接 */
|
||||
connect(): Promise<void>
|
||||
|
||||
/** 断开连接 */
|
||||
disconnect(): Promise<void>
|
||||
|
||||
/** 检查是否已连接 */
|
||||
isConnected(): boolean
|
||||
|
||||
/** 测试连接 */
|
||||
ping(): Promise<boolean>
|
||||
|
||||
/** 获取类型化集合 */
|
||||
collection<T extends object>(name: string): IMongoCollection<T>
|
||||
|
||||
/** 获取数据库接口 */
|
||||
getDatabase(): IMongoDatabase
|
||||
|
||||
/** 获取原生客户端(高级用法) */
|
||||
getNativeClient(): MongoClientType
|
||||
|
||||
/** 获取原生数据库(高级用法) */
|
||||
getNativeDatabase(): Db
|
||||
}
|
||||
```
|
||||
|
||||
## IMongoCollection 接口
|
||||
|
||||
类型安全的集合接口,与原生 MongoDB 类型解耦:
|
||||
|
||||
```typescript
|
||||
interface IMongoCollection<T extends object> {
|
||||
readonly name: string
|
||||
|
||||
// 查询
|
||||
findOne(filter: object, options?: FindOptions): Promise<T | null>
|
||||
find(filter: object, options?: FindOptions): Promise<T[]>
|
||||
countDocuments(filter?: object): Promise<number>
|
||||
|
||||
// 插入
|
||||
insertOne(doc: T): Promise<InsertOneResult>
|
||||
insertMany(docs: T[]): Promise<InsertManyResult>
|
||||
|
||||
// 更新
|
||||
updateOne(filter: object, update: object): Promise<UpdateResult>
|
||||
updateMany(filter: object, update: object): Promise<UpdateResult>
|
||||
findOneAndUpdate(
|
||||
filter: object,
|
||||
update: object,
|
||||
options?: FindOneAndUpdateOptions
|
||||
): Promise<T | null>
|
||||
|
||||
// 删除
|
||||
deleteOne(filter: object): Promise<DeleteResult>
|
||||
deleteMany(filter: object): Promise<DeleteResult>
|
||||
|
||||
// 索引
|
||||
createIndex(
|
||||
spec: Record<string, 1 | -1>,
|
||||
options?: IndexOptions
|
||||
): Promise<string>
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 基本 CRUD
|
||||
|
||||
```typescript
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
score: number
|
||||
}
|
||||
|
||||
const users = mongo.collection<User>('users')
|
||||
|
||||
// 插入
|
||||
await users.insertOne({
|
||||
id: '1',
|
||||
name: 'John',
|
||||
email: 'john@example.com',
|
||||
score: 100
|
||||
})
|
||||
|
||||
// 查询
|
||||
const user = await users.findOne({ name: 'John' })
|
||||
|
||||
const topUsers = await users.find(
|
||||
{ score: { $gte: 100 } },
|
||||
{ sort: { score: -1 }, limit: 10 }
|
||||
)
|
||||
|
||||
// 更新
|
||||
await users.updateOne(
|
||||
{ id: '1' },
|
||||
{ $inc: { score: 10 } }
|
||||
)
|
||||
|
||||
// 删除
|
||||
await users.deleteOne({ id: '1' })
|
||||
```
|
||||
|
||||
### 批量操作
|
||||
|
||||
```typescript
|
||||
// 批量插入
|
||||
await users.insertMany([
|
||||
{ id: '1', name: 'Alice', email: 'alice@example.com', score: 100 },
|
||||
{ id: '2', name: 'Bob', email: 'bob@example.com', score: 200 },
|
||||
{ id: '3', name: 'Carol', email: 'carol@example.com', score: 150 }
|
||||
])
|
||||
|
||||
// 批量更新
|
||||
await users.updateMany(
|
||||
{ score: { $lt: 100 } },
|
||||
{ $set: { status: 'inactive' } }
|
||||
)
|
||||
|
||||
// 批量删除
|
||||
await users.deleteMany({ status: 'inactive' })
|
||||
```
|
||||
|
||||
### 索引管理
|
||||
|
||||
```typescript
|
||||
// 创建索引
|
||||
await users.createIndex({ email: 1 }, { unique: true })
|
||||
await users.createIndex({ score: -1 })
|
||||
await users.createIndex({ name: 1, score: -1 })
|
||||
```
|
||||
|
||||
## 与其他模块集成
|
||||
|
||||
### 与 @esengine/database 集成
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
import { UserRepository, createRepository } from '@esengine/database'
|
||||
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
})
|
||||
await mongo.connect()
|
||||
|
||||
// 使用 UserRepository
|
||||
const userRepo = new UserRepository(mongo)
|
||||
await userRepo.register({ username: 'john', password: '123456' })
|
||||
|
||||
// 使用通用仓库
|
||||
const playerRepo = createRepository<Player>(mongo, 'players')
|
||||
```
|
||||
|
||||
### 与 @esengine/transaction 集成
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
import { createMongoStorage, TransactionManager } from '@esengine/transaction'
|
||||
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
})
|
||||
await mongo.connect()
|
||||
|
||||
// 创建事务存储(共享连接)
|
||||
const storage = createMongoStorage(mongo)
|
||||
await storage.ensureIndexes()
|
||||
|
||||
const txManager = new TransactionManager({ storage })
|
||||
```
|
||||
228
docs/src/content/docs/modules/database-drivers/redis.md
Normal file
228
docs/src/content/docs/modules/database-drivers/redis.md
Normal file
@@ -0,0 +1,228 @@
|
||||
---
|
||||
title: "Redis 连接"
|
||||
description: "Redis 连接管理、自动重连、键前缀"
|
||||
---
|
||||
|
||||
## 配置选项
|
||||
|
||||
```typescript
|
||||
interface RedisConnectionConfig {
|
||||
/** Redis 主机 */
|
||||
host?: string
|
||||
|
||||
/** Redis 端口 */
|
||||
port?: number
|
||||
|
||||
/** 认证密码 */
|
||||
password?: string
|
||||
|
||||
/** 数据库编号 */
|
||||
db?: number
|
||||
|
||||
/** 键前缀 */
|
||||
keyPrefix?: string
|
||||
|
||||
/** 是否自动重连(默认 true) */
|
||||
autoReconnect?: boolean
|
||||
|
||||
/** 重连间隔(毫秒,默认 5000) */
|
||||
reconnectInterval?: number
|
||||
|
||||
/** 最大重连次数(默认 10) */
|
||||
maxReconnectAttempts?: number
|
||||
}
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
import { createRedisConnection, RedisConnectionToken } from '@esengine/database-drivers'
|
||||
|
||||
const redis = createRedisConnection({
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
password: 'your-password',
|
||||
db: 0,
|
||||
keyPrefix: 'game:',
|
||||
autoReconnect: true,
|
||||
reconnectInterval: 5000,
|
||||
maxReconnectAttempts: 10
|
||||
})
|
||||
|
||||
// 事件监听
|
||||
redis.on('connected', () => {
|
||||
console.log('Redis 已连接')
|
||||
})
|
||||
|
||||
redis.on('disconnected', () => {
|
||||
console.log('Redis 已断开')
|
||||
})
|
||||
|
||||
redis.on('error', (event) => {
|
||||
console.error('Redis 错误:', event.error)
|
||||
})
|
||||
|
||||
// 连接
|
||||
await redis.connect()
|
||||
|
||||
// 检查状态
|
||||
console.log('已连接:', redis.isConnected())
|
||||
console.log('Ping:', await redis.ping())
|
||||
```
|
||||
|
||||
## IRedisConnection 接口
|
||||
|
||||
```typescript
|
||||
interface IRedisConnection {
|
||||
/** 连接 ID */
|
||||
readonly id: string
|
||||
|
||||
/** 连接状态 */
|
||||
readonly state: ConnectionState
|
||||
|
||||
/** 建立连接 */
|
||||
connect(): Promise<void>
|
||||
|
||||
/** 断开连接 */
|
||||
disconnect(): Promise<void>
|
||||
|
||||
/** 检查是否已连接 */
|
||||
isConnected(): boolean
|
||||
|
||||
/** 测试连接 */
|
||||
ping(): Promise<boolean>
|
||||
|
||||
/** 获取值 */
|
||||
get(key: string): Promise<string | null>
|
||||
|
||||
/** 设置值(可选 TTL,单位秒) */
|
||||
set(key: string, value: string, ttl?: number): Promise<void>
|
||||
|
||||
/** 删除键 */
|
||||
del(key: string): Promise<boolean>
|
||||
|
||||
/** 检查键是否存在 */
|
||||
exists(key: string): Promise<boolean>
|
||||
|
||||
/** 设置过期时间(秒) */
|
||||
expire(key: string, seconds: number): Promise<boolean>
|
||||
|
||||
/** 获取剩余过期时间(秒) */
|
||||
ttl(key: string): Promise<number>
|
||||
|
||||
/** 获取原生客户端(高级用法) */
|
||||
getNativeClient(): Redis
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 基本操作
|
||||
|
||||
```typescript
|
||||
// 设置值
|
||||
await redis.set('user:1:name', 'John')
|
||||
|
||||
// 设置带过期时间的值(1 小时)
|
||||
await redis.set('session:abc123', 'user-data', 3600)
|
||||
|
||||
// 获取值
|
||||
const name = await redis.get('user:1:name')
|
||||
|
||||
// 检查键是否存在
|
||||
const exists = await redis.exists('user:1:name')
|
||||
|
||||
// 删除键
|
||||
await redis.del('user:1:name')
|
||||
|
||||
// 获取剩余过期时间
|
||||
const ttl = await redis.ttl('session:abc123')
|
||||
```
|
||||
|
||||
### 键前缀
|
||||
|
||||
配置 `keyPrefix` 后,所有操作自动添加前缀:
|
||||
|
||||
```typescript
|
||||
const redis = createRedisConnection({
|
||||
host: 'localhost',
|
||||
keyPrefix: 'game:'
|
||||
})
|
||||
|
||||
// 实际操作的键是 'game:user:1'
|
||||
await redis.set('user:1', 'data')
|
||||
|
||||
// 实际查询的键是 'game:user:1'
|
||||
const data = await redis.get('user:1')
|
||||
```
|
||||
|
||||
### 高级操作
|
||||
|
||||
使用原生客户端进行高级操作:
|
||||
|
||||
```typescript
|
||||
const client = redis.getNativeClient()
|
||||
|
||||
// 使用 Pipeline
|
||||
const pipeline = client.pipeline()
|
||||
pipeline.set('key1', 'value1')
|
||||
pipeline.set('key2', 'value2')
|
||||
pipeline.set('key3', 'value3')
|
||||
await pipeline.exec()
|
||||
|
||||
// 使用事务
|
||||
const multi = client.multi()
|
||||
multi.incr('counter')
|
||||
multi.get('counter')
|
||||
const results = await multi.exec()
|
||||
|
||||
// 使用 Lua 脚本
|
||||
const result = await client.eval(
|
||||
`return redis.call('get', KEYS[1])`,
|
||||
1,
|
||||
'mykey'
|
||||
)
|
||||
```
|
||||
|
||||
## 与事务系统集成
|
||||
|
||||
```typescript
|
||||
import { createRedisConnection } from '@esengine/database-drivers'
|
||||
import { RedisStorage, TransactionManager } from '@esengine/transaction'
|
||||
|
||||
const redis = createRedisConnection({
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
keyPrefix: 'tx:'
|
||||
})
|
||||
await redis.connect()
|
||||
|
||||
// 创建事务存储
|
||||
const storage = new RedisStorage({
|
||||
factory: () => redis.getNativeClient(),
|
||||
prefix: 'tx:'
|
||||
})
|
||||
|
||||
const txManager = new TransactionManager({ storage })
|
||||
```
|
||||
|
||||
## 连接状态
|
||||
|
||||
```typescript
|
||||
type ConnectionState =
|
||||
| 'disconnected' // 未连接
|
||||
| 'connecting' // 连接中
|
||||
| 'connected' // 已连接
|
||||
| 'disconnecting' // 断开中
|
||||
| 'error' // 错误状态
|
||||
```
|
||||
|
||||
## 事件
|
||||
|
||||
| 事件 | 描述 |
|
||||
|------|------|
|
||||
| `connected` | 连接成功 |
|
||||
| `disconnected` | 连接断开 |
|
||||
| `reconnecting` | 正在重连 |
|
||||
| `reconnected` | 重连成功 |
|
||||
| `error` | 发生错误 |
|
||||
140
docs/src/content/docs/modules/database/index.md
Normal file
140
docs/src/content/docs/modules/database/index.md
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
title: "数据库仓库"
|
||||
description: "Repository 模式的数据库操作层,支持 CRUD、分页、软删除"
|
||||
---
|
||||
|
||||
`@esengine/database` 是 ESEngine 的数据库操作层,基于 Repository 模式提供类型安全的 CRUD 操作。
|
||||
|
||||
## 特性
|
||||
|
||||
- **Repository 模式** - 泛型 CRUD 操作,类型安全
|
||||
- **分页查询** - 内置分页支持
|
||||
- **软删除** - 可选的软删除与恢复
|
||||
- **用户管理** - 开箱即用的 UserRepository
|
||||
- **密码安全** - 使用 scrypt 的密码哈希工具
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install @esengine/database @esengine/database-drivers
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 基本仓库
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
import { Repository, createRepository } from '@esengine/database'
|
||||
|
||||
// 定义实体
|
||||
interface Player {
|
||||
id: string
|
||||
name: string
|
||||
score: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// 创建连接
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
})
|
||||
await mongo.connect()
|
||||
|
||||
// 创建仓库
|
||||
const playerRepo = createRepository<Player>(mongo, 'players')
|
||||
|
||||
// CRUD 操作
|
||||
const player = await playerRepo.create({
|
||||
name: 'John',
|
||||
score: 0
|
||||
})
|
||||
|
||||
const found = await playerRepo.findById(player.id)
|
||||
|
||||
await playerRepo.update(player.id, { score: 100 })
|
||||
|
||||
await playerRepo.delete(player.id)
|
||||
```
|
||||
|
||||
### 自定义仓库
|
||||
|
||||
```typescript
|
||||
import { Repository, BaseEntity } from '@esengine/database'
|
||||
import type { IMongoConnection } from '@esengine/database-drivers'
|
||||
|
||||
interface Player extends BaseEntity {
|
||||
name: string
|
||||
score: number
|
||||
rank?: string
|
||||
}
|
||||
|
||||
class PlayerRepository extends Repository<Player> {
|
||||
constructor(connection: IMongoConnection) {
|
||||
super(connection, 'players')
|
||||
}
|
||||
|
||||
async findTopPlayers(limit: number = 10): Promise<Player[]> {
|
||||
return this.findMany({
|
||||
sort: { score: 'desc' },
|
||||
limit
|
||||
})
|
||||
}
|
||||
|
||||
async findByRank(rank: string): Promise<Player[]> {
|
||||
return this.findMany({
|
||||
where: { rank }
|
||||
})
|
||||
}
|
||||
|
||||
async incrementScore(playerId: string, amount: number): Promise<Player | null> {
|
||||
const player = await this.findById(playerId)
|
||||
if (!player) return null
|
||||
return this.update(playerId, { score: player.score + amount })
|
||||
}
|
||||
}
|
||||
|
||||
// 使用
|
||||
const playerRepo = new PlayerRepository(mongo)
|
||||
const topPlayers = await playerRepo.findTopPlayers(5)
|
||||
```
|
||||
|
||||
### 用户仓库
|
||||
|
||||
```typescript
|
||||
import { UserRepository } from '@esengine/database'
|
||||
|
||||
const userRepo = new UserRepository(mongo)
|
||||
|
||||
// 注册新用户
|
||||
const user = await userRepo.register({
|
||||
username: 'john',
|
||||
password: 'securePassword123',
|
||||
email: 'john@example.com'
|
||||
})
|
||||
|
||||
// 认证
|
||||
const authenticated = await userRepo.authenticate('john', 'securePassword123')
|
||||
if (authenticated) {
|
||||
console.log('登录成功:', authenticated.username)
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
await userRepo.changePassword(user.id, 'securePassword123', 'newPassword456')
|
||||
|
||||
// 角色管理
|
||||
await userRepo.addRole(user.id, 'admin')
|
||||
await userRepo.removeRole(user.id, 'admin')
|
||||
|
||||
// 查询用户
|
||||
const admins = await userRepo.findByRole('admin')
|
||||
const john = await userRepo.findByUsername('john')
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
- [仓库 API](/modules/database/repository/) - Repository 详细 API
|
||||
- [用户管理](/modules/database/user/) - UserRepository 用法
|
||||
- [查询语法](/modules/database/query/) - 查询条件语法
|
||||
185
docs/src/content/docs/modules/database/query.md
Normal file
185
docs/src/content/docs/modules/database/query.md
Normal file
@@ -0,0 +1,185 @@
|
||||
---
|
||||
title: "查询语法"
|
||||
description: "查询条件操作符和语法"
|
||||
---
|
||||
|
||||
## 基本查询
|
||||
|
||||
### 精确匹配
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
name: 'John',
|
||||
status: 'active'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 使用操作符
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
score: { $gte: 100 },
|
||||
rank: { $in: ['gold', 'platinum'] }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 查询操作符
|
||||
|
||||
| 操作符 | 描述 | 示例 |
|
||||
|--------|------|------|
|
||||
| `$eq` | 等于 | `{ score: { $eq: 100 } }` |
|
||||
| `$ne` | 不等于 | `{ status: { $ne: 'banned' } }` |
|
||||
| `$gt` | 大于 | `{ score: { $gt: 50 } }` |
|
||||
| `$gte` | 大于等于 | `{ level: { $gte: 10 } }` |
|
||||
| `$lt` | 小于 | `{ age: { $lt: 18 } }` |
|
||||
| `$lte` | 小于等于 | `{ price: { $lte: 100 } }` |
|
||||
| `$in` | 在数组中 | `{ rank: { $in: ['gold', 'platinum'] } }` |
|
||||
| `$nin` | 不在数组中 | `{ status: { $nin: ['banned', 'suspended'] } }` |
|
||||
| `$like` | 模式匹配 | `{ name: { $like: '%john%' } }` |
|
||||
| `$regex` | 正则匹配 | `{ email: { $regex: '@gmail.com$' } }` |
|
||||
|
||||
## 逻辑操作符
|
||||
|
||||
### $or
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
$or: [
|
||||
{ score: { $gte: 1000 } },
|
||||
{ rank: 'legendary' }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### $and
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
$and: [
|
||||
{ score: { $gte: 100 } },
|
||||
{ score: { $lte: 500 } }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 组合使用
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
$or: [
|
||||
{ rank: 'gold' },
|
||||
{ score: { $gte: 1000 } }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 模式匹配
|
||||
|
||||
### $like 语法
|
||||
|
||||
- `%` - 匹配任意字符序列
|
||||
- `_` - 匹配单个字符
|
||||
|
||||
```typescript
|
||||
// 以 'John' 开头
|
||||
{ name: { $like: 'John%' } }
|
||||
|
||||
// 以 'son' 结尾
|
||||
{ name: { $like: '%son' } }
|
||||
|
||||
// 包含 'oh'
|
||||
{ name: { $like: '%oh%' } }
|
||||
|
||||
// 第二个字符是 'o'
|
||||
{ name: { $like: '_o%' } }
|
||||
```
|
||||
|
||||
### $regex 语法
|
||||
|
||||
使用标准正则表达式:
|
||||
|
||||
```typescript
|
||||
// 以 'John' 开头(大小写不敏感)
|
||||
{ name: { $regex: '^john' } }
|
||||
|
||||
// Gmail 邮箱
|
||||
{ email: { $regex: '@gmail\\.com$' } }
|
||||
|
||||
// 包含数字
|
||||
{ username: { $regex: '\\d+' } }
|
||||
```
|
||||
|
||||
## 排序
|
||||
|
||||
```typescript
|
||||
await repo.findMany({
|
||||
sort: {
|
||||
score: 'desc', // 降序
|
||||
name: 'asc' // 升序
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 分页
|
||||
|
||||
### 使用 limit/offset
|
||||
|
||||
```typescript
|
||||
// 第一页
|
||||
await repo.findMany({
|
||||
limit: 20,
|
||||
offset: 0
|
||||
})
|
||||
|
||||
// 第二页
|
||||
await repo.findMany({
|
||||
limit: 20,
|
||||
offset: 20
|
||||
})
|
||||
```
|
||||
|
||||
### 使用 findPaginated
|
||||
|
||||
```typescript
|
||||
const result = await repo.findPaginated(
|
||||
{ page: 2, pageSize: 20 },
|
||||
{ sort: { createdAt: 'desc' } }
|
||||
)
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
// 查找活跃的金牌玩家,分数在 100-1000 之间
|
||||
// 按分数降序排列,取前 10 个
|
||||
const players = await repo.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
rank: 'gold',
|
||||
score: { $gte: 100, $lte: 1000 }
|
||||
},
|
||||
sort: { score: 'desc' },
|
||||
limit: 10
|
||||
})
|
||||
|
||||
// 搜索用户名包含 'john' 或邮箱是 gmail 的用户
|
||||
const users = await repo.findMany({
|
||||
where: {
|
||||
$or: [
|
||||
{ username: { $like: '%john%' } },
|
||||
{ email: { $regex: '@gmail\\.com$' } }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
244
docs/src/content/docs/modules/database/repository.md
Normal file
244
docs/src/content/docs/modules/database/repository.md
Normal file
@@ -0,0 +1,244 @@
|
||||
---
|
||||
title: "Repository API"
|
||||
description: "泛型仓库接口,CRUD 操作、分页、软删除"
|
||||
---
|
||||
|
||||
## 创建仓库
|
||||
|
||||
### 使用工厂函数
|
||||
|
||||
```typescript
|
||||
import { createRepository } from '@esengine/database'
|
||||
|
||||
const playerRepo = createRepository<Player>(mongo, 'players')
|
||||
|
||||
// 启用软删除
|
||||
const playerRepo = createRepository<Player>(mongo, 'players', true)
|
||||
```
|
||||
|
||||
### 继承 Repository
|
||||
|
||||
```typescript
|
||||
import { Repository, BaseEntity } from '@esengine/database'
|
||||
|
||||
interface Player extends BaseEntity {
|
||||
name: string
|
||||
score: number
|
||||
}
|
||||
|
||||
class PlayerRepository extends Repository<Player> {
|
||||
constructor(connection: IMongoConnection) {
|
||||
super(connection, 'players', false) // 第三个参数:启用软删除
|
||||
}
|
||||
|
||||
// 添加自定义方法
|
||||
async findTopPlayers(limit: number): Promise<Player[]> {
|
||||
return this.findMany({
|
||||
sort: { score: 'desc' },
|
||||
limit
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## BaseEntity 接口
|
||||
|
||||
所有实体必须继承 `BaseEntity`:
|
||||
|
||||
```typescript
|
||||
interface BaseEntity {
|
||||
id: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
deletedAt?: Date // 软删除时使用
|
||||
}
|
||||
```
|
||||
|
||||
## 查询方法
|
||||
|
||||
### findById
|
||||
|
||||
```typescript
|
||||
const player = await repo.findById('player-123')
|
||||
```
|
||||
|
||||
### findOne
|
||||
|
||||
```typescript
|
||||
const player = await repo.findOne({
|
||||
where: { name: 'John' }
|
||||
})
|
||||
|
||||
const topPlayer = await repo.findOne({
|
||||
sort: { score: 'desc' }
|
||||
})
|
||||
```
|
||||
|
||||
### findMany
|
||||
|
||||
```typescript
|
||||
// 简单查询
|
||||
const players = await repo.findMany({
|
||||
where: { rank: 'gold' }
|
||||
})
|
||||
|
||||
// 复杂查询
|
||||
const players = await repo.findMany({
|
||||
where: {
|
||||
score: { $gte: 100 },
|
||||
rank: { $in: ['gold', 'platinum'] }
|
||||
},
|
||||
sort: { score: 'desc', name: 'asc' },
|
||||
limit: 10,
|
||||
offset: 0
|
||||
})
|
||||
```
|
||||
|
||||
### findPaginated
|
||||
|
||||
```typescript
|
||||
const result = await repo.findPaginated(
|
||||
{ page: 1, pageSize: 20 },
|
||||
{
|
||||
where: { rank: 'gold' },
|
||||
sort: { score: 'desc' }
|
||||
}
|
||||
)
|
||||
|
||||
console.log(result.data) // Player[]
|
||||
console.log(result.total) // 总数量
|
||||
console.log(result.totalPages) // 总页数
|
||||
console.log(result.hasNext) // 是否有下一页
|
||||
console.log(result.hasPrev) // 是否有上一页
|
||||
```
|
||||
|
||||
### count
|
||||
|
||||
```typescript
|
||||
const count = await repo.count({
|
||||
where: { rank: 'gold' }
|
||||
})
|
||||
```
|
||||
|
||||
### exists
|
||||
|
||||
```typescript
|
||||
const exists = await repo.exists({
|
||||
where: { email: 'john@example.com' }
|
||||
})
|
||||
```
|
||||
|
||||
## 创建方法
|
||||
|
||||
### create
|
||||
|
||||
```typescript
|
||||
const player = await repo.create({
|
||||
name: 'John',
|
||||
score: 0
|
||||
})
|
||||
// 自动生成 id, createdAt, updatedAt
|
||||
```
|
||||
|
||||
### createMany
|
||||
|
||||
```typescript
|
||||
const players = await repo.createMany([
|
||||
{ name: 'Alice', score: 100 },
|
||||
{ name: 'Bob', score: 200 },
|
||||
{ name: 'Carol', score: 150 }
|
||||
])
|
||||
```
|
||||
|
||||
## 更新方法
|
||||
|
||||
### update
|
||||
|
||||
```typescript
|
||||
const updated = await repo.update('player-123', {
|
||||
score: 200,
|
||||
rank: 'gold'
|
||||
})
|
||||
// 自动更新 updatedAt
|
||||
```
|
||||
|
||||
## 删除方法
|
||||
|
||||
### delete
|
||||
|
||||
```typescript
|
||||
// 普通删除
|
||||
await repo.delete('player-123')
|
||||
|
||||
// 软删除(如果启用)
|
||||
// 实际是设置 deletedAt 字段
|
||||
```
|
||||
|
||||
### deleteMany
|
||||
|
||||
```typescript
|
||||
const count = await repo.deleteMany({
|
||||
where: { score: { $lt: 10 } }
|
||||
})
|
||||
```
|
||||
|
||||
## 软删除
|
||||
|
||||
### 启用软删除
|
||||
|
||||
```typescript
|
||||
const repo = createRepository<Player>(mongo, 'players', true)
|
||||
```
|
||||
|
||||
### 查询行为
|
||||
|
||||
```typescript
|
||||
// 默认排除软删除记录
|
||||
const players = await repo.findMany()
|
||||
|
||||
// 包含软删除记录
|
||||
const allPlayers = await repo.findMany({
|
||||
includeSoftDeleted: true
|
||||
})
|
||||
```
|
||||
|
||||
### 恢复记录
|
||||
|
||||
```typescript
|
||||
await repo.restore('player-123')
|
||||
```
|
||||
|
||||
## QueryOptions
|
||||
|
||||
```typescript
|
||||
interface QueryOptions<T> {
|
||||
/** 查询条件 */
|
||||
where?: WhereCondition<T>
|
||||
|
||||
/** 排序 */
|
||||
sort?: Partial<Record<keyof T, 'asc' | 'desc'>>
|
||||
|
||||
/** 限制数量 */
|
||||
limit?: number
|
||||
|
||||
/** 偏移量 */
|
||||
offset?: number
|
||||
|
||||
/** 包含软删除记录(仅在启用软删除时有效) */
|
||||
includeSoftDeleted?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
## PaginatedResult
|
||||
|
||||
```typescript
|
||||
interface PaginatedResult<T> {
|
||||
data: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}
|
||||
```
|
||||
277
docs/src/content/docs/modules/database/user.md
Normal file
277
docs/src/content/docs/modules/database/user.md
Normal file
@@ -0,0 +1,277 @@
|
||||
---
|
||||
title: "用户管理"
|
||||
description: "UserRepository 用户注册、认证、角色管理"
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
`UserRepository` 提供开箱即用的用户管理功能:
|
||||
|
||||
- 用户注册与认证
|
||||
- 密码哈希(使用 scrypt)
|
||||
- 角色管理
|
||||
- 账户状态管理
|
||||
|
||||
## 快速开始
|
||||
|
||||
```typescript
|
||||
import { createMongoConnection } from '@esengine/database-drivers'
|
||||
import { UserRepository } from '@esengine/database'
|
||||
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
})
|
||||
await mongo.connect()
|
||||
|
||||
const userRepo = new UserRepository(mongo)
|
||||
```
|
||||
|
||||
## 用户注册
|
||||
|
||||
```typescript
|
||||
const user = await userRepo.register({
|
||||
username: 'john',
|
||||
password: 'securePassword123',
|
||||
email: 'john@example.com', // 可选
|
||||
displayName: 'John Doe', // 可选
|
||||
roles: ['player'] // 可选,默认 []
|
||||
})
|
||||
|
||||
console.log(user)
|
||||
// {
|
||||
// id: 'uuid-...',
|
||||
// username: 'john',
|
||||
// email: 'john@example.com',
|
||||
// displayName: 'John Doe',
|
||||
// roles: ['player'],
|
||||
// status: 'active',
|
||||
// createdAt: Date,
|
||||
// updatedAt: Date
|
||||
// }
|
||||
```
|
||||
|
||||
**注意**:`register` 返回的 `SafeUser` 不包含密码哈希。
|
||||
|
||||
## 用户认证
|
||||
|
||||
```typescript
|
||||
const user = await userRepo.authenticate('john', 'securePassword123')
|
||||
|
||||
if (user) {
|
||||
console.log('登录成功:', user.username)
|
||||
} else {
|
||||
console.log('用户名或密码错误')
|
||||
}
|
||||
```
|
||||
|
||||
## 密码管理
|
||||
|
||||
### 修改密码
|
||||
|
||||
```typescript
|
||||
const success = await userRepo.changePassword(
|
||||
userId,
|
||||
'oldPassword123',
|
||||
'newPassword456'
|
||||
)
|
||||
|
||||
if (success) {
|
||||
console.log('密码修改成功')
|
||||
} else {
|
||||
console.log('原密码错误')
|
||||
}
|
||||
```
|
||||
|
||||
### 重置密码
|
||||
|
||||
```typescript
|
||||
// 管理员直接重置密码
|
||||
const success = await userRepo.resetPassword(userId, 'newPassword123')
|
||||
```
|
||||
|
||||
## 角色管理
|
||||
|
||||
### 添加角色
|
||||
|
||||
```typescript
|
||||
await userRepo.addRole(userId, 'admin')
|
||||
await userRepo.addRole(userId, 'moderator')
|
||||
```
|
||||
|
||||
### 移除角色
|
||||
|
||||
```typescript
|
||||
await userRepo.removeRole(userId, 'moderator')
|
||||
```
|
||||
|
||||
### 查询角色
|
||||
|
||||
```typescript
|
||||
// 查找所有管理员
|
||||
const admins = await userRepo.findByRole('admin')
|
||||
|
||||
// 检查用户是否有某角色
|
||||
const user = await userRepo.findById(userId)
|
||||
const isAdmin = user?.roles.includes('admin')
|
||||
```
|
||||
|
||||
## 查询用户
|
||||
|
||||
### 按用户名查找
|
||||
|
||||
```typescript
|
||||
const user = await userRepo.findByUsername('john')
|
||||
```
|
||||
|
||||
### 按邮箱查找
|
||||
|
||||
```typescript
|
||||
const user = await userRepo.findByEmail('john@example.com')
|
||||
```
|
||||
|
||||
### 按角色查找
|
||||
|
||||
```typescript
|
||||
const admins = await userRepo.findByRole('admin')
|
||||
```
|
||||
|
||||
### 使用继承的方法
|
||||
|
||||
```typescript
|
||||
// 分页查询
|
||||
const result = await userRepo.findPaginated(
|
||||
{ page: 1, pageSize: 20 },
|
||||
{
|
||||
where: { status: 'active' },
|
||||
sort: { createdAt: 'desc' }
|
||||
}
|
||||
)
|
||||
|
||||
// 复杂查询
|
||||
const users = await userRepo.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
roles: { $in: ['admin', 'moderator'] }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 账户状态
|
||||
|
||||
```typescript
|
||||
type UserStatus = 'active' | 'inactive' | 'banned' | 'suspended'
|
||||
```
|
||||
|
||||
### 更新状态
|
||||
|
||||
```typescript
|
||||
await userRepo.update(userId, { status: 'banned' })
|
||||
```
|
||||
|
||||
### 查询特定状态
|
||||
|
||||
```typescript
|
||||
const activeUsers = await userRepo.findMany({
|
||||
where: { status: 'active' }
|
||||
})
|
||||
|
||||
const bannedUsers = await userRepo.findMany({
|
||||
where: { status: 'banned' }
|
||||
})
|
||||
```
|
||||
|
||||
## 类型定义
|
||||
|
||||
### UserEntity
|
||||
|
||||
```typescript
|
||||
interface UserEntity extends BaseEntity {
|
||||
username: string
|
||||
passwordHash: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
roles: string[]
|
||||
status: UserStatus
|
||||
lastLoginAt?: Date
|
||||
}
|
||||
```
|
||||
|
||||
### SafeUser
|
||||
|
||||
```typescript
|
||||
type SafeUser = Omit<UserEntity, 'passwordHash'>
|
||||
```
|
||||
|
||||
### CreateUserParams
|
||||
|
||||
```typescript
|
||||
interface CreateUserParams {
|
||||
username: string
|
||||
password: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
roles?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
## 密码工具
|
||||
|
||||
独立的密码工具函数:
|
||||
|
||||
```typescript
|
||||
import { hashPassword, verifyPassword } from '@esengine/database'
|
||||
|
||||
// 哈希密码
|
||||
const hash = await hashPassword('myPassword123')
|
||||
|
||||
// 验证密码
|
||||
const isValid = await verifyPassword('myPassword123', hash)
|
||||
```
|
||||
|
||||
### 安全说明
|
||||
|
||||
- 使用 Node.js 内置的 `scrypt` 算法
|
||||
- 自动生成随机盐值
|
||||
- 默认使用安全的迭代参数
|
||||
- 哈希格式:`salt:hash`(均为 hex 编码)
|
||||
|
||||
## 扩展 UserRepository
|
||||
|
||||
```typescript
|
||||
import { UserRepository, UserEntity } from '@esengine/database'
|
||||
|
||||
interface GameUser extends UserEntity {
|
||||
level: number
|
||||
experience: number
|
||||
coins: number
|
||||
}
|
||||
|
||||
class GameUserRepository extends UserRepository {
|
||||
// 重写集合名
|
||||
constructor(connection: IMongoConnection) {
|
||||
super(connection, 'game_users')
|
||||
}
|
||||
|
||||
// 添加游戏相关方法
|
||||
async addExperience(userId: string, amount: number): Promise<GameUser | null> {
|
||||
const user = await this.findById(userId) as GameUser | null
|
||||
if (!user) return null
|
||||
|
||||
const newExp = user.experience + amount
|
||||
const newLevel = Math.floor(newExp / 1000) + 1
|
||||
|
||||
return this.update(userId, {
|
||||
experience: newExp,
|
||||
level: newLevel
|
||||
}) as Promise<GameUser | null>
|
||||
}
|
||||
|
||||
async findTopPlayers(limit: number = 10): Promise<GameUser[]> {
|
||||
return this.findMany({
|
||||
sort: { level: 'desc', experience: 'desc' },
|
||||
limit
|
||||
}) as Promise<GameUser[]>
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -37,6 +37,13 @@ ESEngine 提供了丰富的功能模块,可以按需引入到你的项目中
|
||||
| [网络同步](/modules/network/) | `@esengine/network` | 多人游戏网络同步 |
|
||||
| [事务系统](/modules/transaction/) | `@esengine/transaction` | 游戏事务处理,支持分布式事务 |
|
||||
|
||||
### 数据库模块
|
||||
|
||||
| 模块 | 包名 | 描述 |
|
||||
|------|------|------|
|
||||
| [数据库驱动](/modules/database-drivers/) | `@esengine/database-drivers` | MongoDB、Redis 连接管理 |
|
||||
| [数据库仓库](/modules/database/) | `@esengine/database` | Repository 模式数据操作 |
|
||||
|
||||
## 安装
|
||||
|
||||
所有模块都可以独立安装:
|
||||
|
||||
@@ -92,6 +92,355 @@ const token = jwtProvider.sign({
|
||||
const payload = jwtProvider.decode(token)
|
||||
```
|
||||
|
||||
### 自定义提供者
|
||||
|
||||
你可以通过实现 `IAuthProvider` 接口来创建自定义认证提供者,以集成任何认证系统(如 OAuth、LDAP、自定义数据库认证等)。
|
||||
|
||||
#### IAuthProvider 接口
|
||||
|
||||
```typescript
|
||||
interface IAuthProvider<TUser = unknown, TCredentials = unknown> {
|
||||
/** 提供者名称 */
|
||||
readonly name: string;
|
||||
|
||||
/** 验证凭证 */
|
||||
verify(credentials: TCredentials): Promise<AuthResult<TUser>>;
|
||||
|
||||
/** 刷新令牌(可选) */
|
||||
refresh?(token: string): Promise<AuthResult<TUser>>;
|
||||
|
||||
/** 撤销令牌(可选) */
|
||||
revoke?(token: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
interface AuthResult<TUser> {
|
||||
success: boolean;
|
||||
user?: TUser;
|
||||
error?: string;
|
||||
errorCode?: AuthErrorCode;
|
||||
token?: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
type AuthErrorCode =
|
||||
| 'INVALID_CREDENTIALS'
|
||||
| 'EXPIRED_TOKEN'
|
||||
| 'INVALID_TOKEN'
|
||||
| 'USER_NOT_FOUND'
|
||||
| 'ACCOUNT_DISABLED'
|
||||
| 'RATE_LIMITED'
|
||||
| 'INSUFFICIENT_PERMISSIONS';
|
||||
```
|
||||
|
||||
#### 自定义提供者示例
|
||||
|
||||
**示例 1:数据库密码认证**
|
||||
|
||||
```typescript
|
||||
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
username: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
interface PasswordCredentials {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
class DatabaseAuthProvider implements IAuthProvider<User, PasswordCredentials> {
|
||||
readonly name = 'database'
|
||||
|
||||
async verify(credentials: PasswordCredentials): Promise<AuthResult<User>> {
|
||||
const { username, password } = credentials
|
||||
|
||||
// 从数据库查询用户
|
||||
const user = await db.users.findByUsername(username)
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: '用户不存在',
|
||||
errorCode: 'USER_NOT_FOUND'
|
||||
}
|
||||
}
|
||||
|
||||
// 验证密码(使用 bcrypt 等库)
|
||||
const isValid = await bcrypt.compare(password, user.passwordHash)
|
||||
if (!isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: '密码错误',
|
||||
errorCode: 'INVALID_CREDENTIALS'
|
||||
}
|
||||
}
|
||||
|
||||
// 检查账号状态
|
||||
if (user.disabled) {
|
||||
return {
|
||||
success: false,
|
||||
error: '账号已禁用',
|
||||
errorCode: 'ACCOUNT_DISABLED'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
roles: user.roles
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**示例 2:OAuth/第三方认证**
|
||||
|
||||
```typescript
|
||||
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
|
||||
|
||||
interface OAuthUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
provider: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
interface OAuthCredentials {
|
||||
provider: 'google' | 'github' | 'discord'
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
class OAuthProvider implements IAuthProvider<OAuthUser, OAuthCredentials> {
|
||||
readonly name = 'oauth'
|
||||
|
||||
async verify(credentials: OAuthCredentials): Promise<AuthResult<OAuthUser>> {
|
||||
const { provider, accessToken } = credentials
|
||||
|
||||
try {
|
||||
// 根据提供商验证 token
|
||||
const profile = await this.fetchUserProfile(provider, accessToken)
|
||||
|
||||
// 查找或创建本地用户
|
||||
let user = await db.users.findByOAuth(provider, profile.id)
|
||||
if (!user) {
|
||||
user = await db.users.create({
|
||||
oauthProvider: provider,
|
||||
oauthId: profile.id,
|
||||
email: profile.email,
|
||||
name: profile.name,
|
||||
roles: ['player']
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
provider,
|
||||
roles: user.roles
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'OAuth 验证失败',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchUserProfile(provider: string, token: string) {
|
||||
switch (provider) {
|
||||
case 'google':
|
||||
return fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).then(r => r.json())
|
||||
case 'github':
|
||||
return fetch('https://api.github.com/user', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).then(r => r.json())
|
||||
// 其他提供商...
|
||||
default:
|
||||
throw new Error(`不支持的提供商: ${provider}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**示例 3:API Key 认证**
|
||||
|
||||
```typescript
|
||||
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
|
||||
|
||||
interface ApiUser {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
rateLimit: number
|
||||
}
|
||||
|
||||
class ApiKeyAuthProvider implements IAuthProvider<ApiUser, string> {
|
||||
readonly name = 'api-key'
|
||||
|
||||
private revokedKeys = new Set<string>()
|
||||
|
||||
async verify(apiKey: string): Promise<AuthResult<ApiUser>> {
|
||||
if (!apiKey || !apiKey.startsWith('sk_')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API Key 格式无效',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
}
|
||||
}
|
||||
|
||||
if (this.revokedKeys.has(apiKey)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API Key 已被撤销',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
}
|
||||
}
|
||||
|
||||
// 从数据库查询 API Key
|
||||
const keyData = await db.apiKeys.findByKey(apiKey)
|
||||
if (!keyData) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API Key 不存在',
|
||||
errorCode: 'INVALID_CREDENTIALS'
|
||||
}
|
||||
}
|
||||
|
||||
// 检查过期
|
||||
if (keyData.expiresAt && keyData.expiresAt < Date.now()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API Key 已过期',
|
||||
errorCode: 'EXPIRED_TOKEN'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: keyData.userId,
|
||||
name: keyData.name,
|
||||
roles: keyData.roles,
|
||||
rateLimit: keyData.rateLimit
|
||||
},
|
||||
expiresAt: keyData.expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
async revoke(apiKey: string): Promise<boolean> {
|
||||
this.revokedKeys.add(apiKey)
|
||||
await db.apiKeys.revoke(apiKey)
|
||||
return true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 使用自定义提供者
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server'
|
||||
import { withAuth } from '@esengine/server/auth'
|
||||
|
||||
// 创建自定义提供者
|
||||
const dbAuthProvider = new DatabaseAuthProvider()
|
||||
|
||||
// 或使用 OAuth 提供者
|
||||
const oauthProvider = new OAuthProvider()
|
||||
|
||||
// 使用自定义提供者
|
||||
const server = withAuth(await createServer({ port: 3000 }), {
|
||||
provider: dbAuthProvider, // 或 oauthProvider
|
||||
|
||||
// 从 WebSocket 连接请求中提取凭证
|
||||
extractCredentials: (req) => {
|
||||
const url = new URL(req.url, 'http://localhost')
|
||||
|
||||
// 对于数据库认证:从查询参数获取
|
||||
const username = url.searchParams.get('username')
|
||||
const password = url.searchParams.get('password')
|
||||
if (username && password) {
|
||||
return { username, password }
|
||||
}
|
||||
|
||||
// 对于 OAuth:从 token 参数获取
|
||||
const provider = url.searchParams.get('provider')
|
||||
const accessToken = url.searchParams.get('access_token')
|
||||
if (provider && accessToken) {
|
||||
return { provider, accessToken }
|
||||
}
|
||||
|
||||
// 对于 API Key:从请求头获取
|
||||
const apiKey = req.headers['x-api-key']
|
||||
if (apiKey) {
|
||||
return apiKey as string
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
onAuthFailure: (conn, error) => {
|
||||
console.log(`认证失败: ${error.errorCode} - ${error.error}`)
|
||||
}
|
||||
})
|
||||
|
||||
await server.start()
|
||||
```
|
||||
|
||||
#### 组合多个提供者
|
||||
|
||||
你可以创建一个复合提供者来支持多种认证方式:
|
||||
|
||||
```typescript
|
||||
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
|
||||
|
||||
interface MultiAuthCredentials {
|
||||
type: 'jwt' | 'oauth' | 'apikey' | 'password'
|
||||
data: unknown
|
||||
}
|
||||
|
||||
class MultiAuthProvider implements IAuthProvider<User, MultiAuthCredentials> {
|
||||
readonly name = 'multi'
|
||||
|
||||
constructor(
|
||||
private jwtProvider: JwtAuthProvider<User>,
|
||||
private oauthProvider: OAuthProvider,
|
||||
private apiKeyProvider: ApiKeyAuthProvider,
|
||||
private dbProvider: DatabaseAuthProvider
|
||||
) {}
|
||||
|
||||
async verify(credentials: MultiAuthCredentials): Promise<AuthResult<User>> {
|
||||
switch (credentials.type) {
|
||||
case 'jwt':
|
||||
return this.jwtProvider.verify(credentials.data as string)
|
||||
case 'oauth':
|
||||
return this.oauthProvider.verify(credentials.data as OAuthCredentials)
|
||||
case 'apikey':
|
||||
return this.apiKeyProvider.verify(credentials.data as string)
|
||||
case 'password':
|
||||
return this.dbProvider.verify(credentials.data as PasswordCredentials)
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
error: '不支持的认证类型',
|
||||
errorCode: 'INVALID_CREDENTIALS'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Session 提供者
|
||||
|
||||
使用服务端会话实现有状态认证:
|
||||
|
||||
441
docs/src/content/docs/modules/network/distributed.md
Normal file
441
docs/src/content/docs/modules/network/distributed.md
Normal file
@@ -0,0 +1,441 @@
|
||||
---
|
||||
title: "分布式房间"
|
||||
description: "使用 DistributedRoomManager 实现多服务器房间管理"
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
分布式房间支持允许多个服务器实例共享房间注册表,实现跨服务器玩家路由和故障转移。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Server A Server B Server C │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Room 1 │ │ Room 3 │ │ Room 5 │ │
|
||||
│ │ Room 2 │ │ Room 4 │ │ Room 6 │ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────────┼─────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ IDistributedAdapter │ │
|
||||
│ │ (Redis / Memory) │ │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 单机模式(测试用)
|
||||
|
||||
```typescript
|
||||
import {
|
||||
DistributedRoomManager,
|
||||
MemoryAdapter,
|
||||
Room
|
||||
} from '@esengine/server';
|
||||
|
||||
// 定义房间类型
|
||||
class GameRoom extends Room {
|
||||
maxPlayers = 4;
|
||||
}
|
||||
|
||||
// 创建适配器和管理器
|
||||
const adapter = new MemoryAdapter();
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'localhost',
|
||||
serverPort: 3000
|
||||
}, (conn, type, data) => conn.send(JSON.stringify({ type, data })));
|
||||
|
||||
// 注册房间类型
|
||||
manager.define('game', GameRoom);
|
||||
|
||||
// 启动管理器
|
||||
await manager.start();
|
||||
|
||||
// 分布式加入/创建房间
|
||||
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
|
||||
if ('redirect' in result) {
|
||||
// 玩家应连接到其他服务器
|
||||
console.log(`重定向到: ${result.redirect}`);
|
||||
} else {
|
||||
// 玩家加入本地房间
|
||||
const { room, player } = result;
|
||||
}
|
||||
|
||||
// 优雅关闭
|
||||
await manager.stop(true);
|
||||
```
|
||||
|
||||
### 多服务器模式(生产用)
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
import { DistributedRoomManager, RedisAdapter } from '@esengine/server';
|
||||
|
||||
const adapter = new RedisAdapter({
|
||||
factory: () => new Redis({
|
||||
host: 'redis.example.com',
|
||||
port: 6379
|
||||
}),
|
||||
prefix: 'game:',
|
||||
serverTtl: 30,
|
||||
snapshotTtl: 86400
|
||||
});
|
||||
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: process.env.SERVER_ID,
|
||||
serverAddress: process.env.PUBLIC_IP,
|
||||
serverPort: 3000,
|
||||
heartbeatInterval: 5000,
|
||||
snapshotInterval: 30000,
|
||||
enableFailover: true,
|
||||
capacity: 100
|
||||
}, sendFn);
|
||||
```
|
||||
|
||||
## DistributedRoomManager
|
||||
|
||||
### 配置选项
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `serverId` | `string` | 必填 | 服务器唯一标识 |
|
||||
| `serverAddress` | `string` | 必填 | 客户端连接的公开地址 |
|
||||
| `serverPort` | `number` | 必填 | 服务器端口 |
|
||||
| `heartbeatInterval` | `number` | `5000` | 心跳间隔(毫秒) |
|
||||
| `snapshotInterval` | `number` | `30000` | 状态快照间隔,0 禁用 |
|
||||
| `migrationTimeout` | `number` | `10000` | 房间迁移超时 |
|
||||
| `enableFailover` | `boolean` | `true` | 启用自动故障转移 |
|
||||
| `capacity` | `number` | `100` | 本服务器最大房间数 |
|
||||
|
||||
### 生命周期方法
|
||||
|
||||
#### start()
|
||||
|
||||
启动分布式房间管理器。连接适配器、注册服务器、启动心跳。
|
||||
|
||||
```typescript
|
||||
await manager.start();
|
||||
```
|
||||
|
||||
#### stop(graceful?)
|
||||
|
||||
停止管理器。如果 `graceful=true`,将服务器标记为 draining 并保存所有房间快照。
|
||||
|
||||
```typescript
|
||||
await manager.stop(true);
|
||||
```
|
||||
|
||||
### 路由方法
|
||||
|
||||
#### joinOrCreateDistributed()
|
||||
|
||||
分布式感知的加入或创建房间。返回本地房间的 `{ room, player }` 或远程房间的 `{ redirect: string }`。
|
||||
|
||||
```typescript
|
||||
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
|
||||
|
||||
if ('redirect' in result) {
|
||||
// 客户端应重定向到其他服务器
|
||||
res.json({ redirect: result.redirect });
|
||||
} else {
|
||||
// 玩家加入了本地房间
|
||||
const { room, player } = result;
|
||||
}
|
||||
```
|
||||
|
||||
#### route()
|
||||
|
||||
将玩家路由到合适的房间/服务器。
|
||||
|
||||
```typescript
|
||||
const result = await manager.route({
|
||||
roomType: 'game',
|
||||
playerId: 'p1'
|
||||
});
|
||||
|
||||
switch (result.type) {
|
||||
case 'local': // 房间在本服务器
|
||||
break;
|
||||
case 'redirect': // 房间在其他服务器
|
||||
// result.serverAddress 包含目标服务器地址
|
||||
break;
|
||||
case 'create': // 没有可用房间,需要创建
|
||||
break;
|
||||
case 'unavailable': // 无法找到或创建房间
|
||||
// result.reason 包含错误信息
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### 状态管理
|
||||
|
||||
#### saveSnapshot()
|
||||
|
||||
手动保存房间状态快照。
|
||||
|
||||
```typescript
|
||||
await manager.saveSnapshot(roomId);
|
||||
```
|
||||
|
||||
#### restoreFromSnapshot()
|
||||
|
||||
从保存的快照恢复房间。
|
||||
|
||||
```typescript
|
||||
const success = await manager.restoreFromSnapshot(roomId);
|
||||
```
|
||||
|
||||
### 查询方法
|
||||
|
||||
#### getServers()
|
||||
|
||||
获取所有在线服务器。
|
||||
|
||||
```typescript
|
||||
const servers = await manager.getServers();
|
||||
```
|
||||
|
||||
#### queryDistributedRooms()
|
||||
|
||||
查询所有服务器上的房间。
|
||||
|
||||
```typescript
|
||||
const rooms = await manager.queryDistributedRooms({
|
||||
roomType: 'game',
|
||||
hasSpace: true,
|
||||
notLocked: true
|
||||
});
|
||||
```
|
||||
|
||||
## IDistributedAdapter
|
||||
|
||||
分布式后端的接口。实现此接口以支持 Redis、消息队列等。
|
||||
|
||||
### 内置适配器
|
||||
|
||||
#### MemoryAdapter
|
||||
|
||||
用于测试和单机模式的内存实现。
|
||||
|
||||
```typescript
|
||||
const adapter = new MemoryAdapter({
|
||||
serverTtl: 15000, // 无心跳后服务器离线时间(毫秒)
|
||||
enableTtlCheck: true, // 启用自动 TTL 检查
|
||||
ttlCheckInterval: 5000 // TTL 检查间隔(毫秒)
|
||||
});
|
||||
```
|
||||
|
||||
#### RedisAdapter
|
||||
|
||||
用于生产环境多服务器部署的 Redis 实现。
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
import { RedisAdapter } from '@esengine/server';
|
||||
|
||||
const adapter = new RedisAdapter({
|
||||
factory: () => new Redis('redis://localhost:6379'),
|
||||
prefix: 'game:', // 键前缀(默认: 'dist:')
|
||||
serverTtl: 30, // 服务器 TTL(秒,默认: 30)
|
||||
roomTtl: 0, // 房间 TTL,0 = 永不过期(默认: 0)
|
||||
snapshotTtl: 86400, // 快照 TTL(秒,默认: 24 小时)
|
||||
channel: 'game:events' // Pub/Sub 频道(默认: 'distributed:events')
|
||||
});
|
||||
```
|
||||
|
||||
**RedisAdapter 配置:**
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `factory` | `() => RedisClient` | 必填 | Redis 客户端工厂(惰性连接) |
|
||||
| `prefix` | `string` | `'dist:'` | 所有 Redis 键的前缀 |
|
||||
| `serverTtl` | `number` | `30` | 服务器 TTL(秒) |
|
||||
| `roomTtl` | `number` | `0` | 房间 TTL(秒),0 = 不过期 |
|
||||
| `snapshotTtl` | `number` | `86400` | 快照 TTL(秒) |
|
||||
| `channel` | `string` | `'distributed:events'` | Pub/Sub 频道名 |
|
||||
|
||||
**功能特性:**
|
||||
- 带自动心跳 TTL 的服务器注册
|
||||
- 跨服务器查找的房间注册
|
||||
- 可配置 TTL 的状态快照
|
||||
- 跨服务器事件的 Pub/Sub
|
||||
- 使用 Redis SET NX 的分布式锁
|
||||
|
||||
### 自定义适配器
|
||||
|
||||
```typescript
|
||||
import type { IDistributedAdapter } from '@esengine/server';
|
||||
|
||||
class MyAdapter implements IDistributedAdapter {
|
||||
// 生命周期
|
||||
async connect(): Promise<void> { }
|
||||
async disconnect(): Promise<void> { }
|
||||
isConnected(): boolean { return true; }
|
||||
|
||||
// 服务器注册
|
||||
async registerServer(server: ServerRegistration): Promise<void> { }
|
||||
async unregisterServer(serverId: string): Promise<void> { }
|
||||
async heartbeat(serverId: string): Promise<void> { }
|
||||
async getServers(): Promise<ServerRegistration[]> { return []; }
|
||||
|
||||
// 房间注册
|
||||
async registerRoom(room: RoomRegistration): Promise<void> { }
|
||||
async unregisterRoom(roomId: string): Promise<void> { }
|
||||
async queryRooms(query: RoomQuery): Promise<RoomRegistration[]> { return []; }
|
||||
async findAvailableRoom(roomType: string): Promise<RoomRegistration | null> { return null; }
|
||||
|
||||
// 状态快照
|
||||
async saveSnapshot(snapshot: RoomSnapshot): Promise<void> { }
|
||||
async loadSnapshot(roomId: string): Promise<RoomSnapshot | null> { return null; }
|
||||
|
||||
// 发布/订阅
|
||||
async publish(event: DistributedEvent): Promise<void> { }
|
||||
async subscribe(pattern: string, handler: Function): Promise<() => void> { return () => {}; }
|
||||
|
||||
// 分布式锁
|
||||
async acquireLock(key: string, ttlMs: number): Promise<boolean> { return true; }
|
||||
async releaseLock(key: string): Promise<void> { }
|
||||
}
|
||||
```
|
||||
|
||||
## 玩家路由流程
|
||||
|
||||
```
|
||||
客户端 服务器 A 服务器 B
|
||||
│ │ │
|
||||
│─── joinOrCreate ────────►│ │
|
||||
│ │ │
|
||||
│ │── findAvailableRoom() ───►│
|
||||
│ │◄──── 服务器 B 上有房间 ────│
|
||||
│ │ │
|
||||
│◄─── redirect: B:3001 ────│ │
|
||||
│ │ │
|
||||
│───────────────── 连接到服务器 B ────────────────────►│
|
||||
│ │ │
|
||||
│◄─────────────────────────────── 已加入 ─────────────│
|
||||
```
|
||||
|
||||
## 事件类型
|
||||
|
||||
分布式系统发布以下事件:
|
||||
|
||||
| 事件 | 描述 |
|
||||
|------|------|
|
||||
| `server:online` | 服务器上线 |
|
||||
| `server:offline` | 服务器离线 |
|
||||
| `server:draining` | 服务器正在排空 |
|
||||
| `room:created` | 房间已创建 |
|
||||
| `room:disposed` | 房间已销毁 |
|
||||
| `room:updated` | 房间信息已更新 |
|
||||
| `room:message` | 跨服务器房间消息 |
|
||||
| `room:migrated` | 房间已迁移到其他服务器 |
|
||||
| `player:joined` | 玩家加入房间 |
|
||||
| `player:left` | 玩家离开房间 |
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用唯一服务器 ID** - 使用主机名、容器 ID 或 UUID
|
||||
|
||||
2. **配置合适的心跳** - 在新鲜度和网络开销之间平衡
|
||||
|
||||
3. **为有状态房间启用快照** - 确保房间状态在服务器重启后存活
|
||||
|
||||
4. **优雅处理重定向** - 客户端应重新连接到目标服务器
|
||||
```typescript
|
||||
// 客户端处理重定向
|
||||
if (response.redirect) {
|
||||
await client.disconnect();
|
||||
await client.connect(response.redirect);
|
||||
await client.joinRoom(roomId);
|
||||
}
|
||||
```
|
||||
|
||||
5. **使用分布式锁** - 防止 joinOrCreate 中的竞态条件
|
||||
|
||||
## 使用 createServer 集成
|
||||
|
||||
最简单的使用方式是通过 `createServer` 的 `distributed` 配置:
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server';
|
||||
import { RedisAdapter, Room } from '@esengine/server';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
class GameRoom extends Room {
|
||||
maxPlayers = 4;
|
||||
}
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
distributed: {
|
||||
enabled: true,
|
||||
adapter: new RedisAdapter({ factory: () => new Redis() }),
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'ws://192.168.1.100',
|
||||
serverPort: 3000,
|
||||
enableFailover: true,
|
||||
capacity: 100
|
||||
}
|
||||
});
|
||||
|
||||
server.define('game', GameRoom);
|
||||
await server.start();
|
||||
```
|
||||
|
||||
当客户端调用 `JoinRoom` API 时,服务器会自动:
|
||||
1. 查找可用房间(本地或远程)
|
||||
2. 如果房间在其他服务器,发送 `$redirect` 消息给客户端
|
||||
3. 客户端收到重定向消息后连接到目标服务器
|
||||
|
||||
## 负载均衡
|
||||
|
||||
使用 `LoadBalancedRouter` 进行服务器选择:
|
||||
|
||||
```typescript
|
||||
import { LoadBalancedRouter, createLoadBalancedRouter } from '@esengine/server';
|
||||
|
||||
// 使用工厂函数
|
||||
const router = createLoadBalancedRouter('least-players');
|
||||
|
||||
// 或直接创建
|
||||
const router = new LoadBalancedRouter({
|
||||
strategy: 'least-rooms', // 选择房间数最少的服务器
|
||||
preferLocal: true // 优先选择本地服务器
|
||||
});
|
||||
|
||||
// 可用策略
|
||||
// - 'round-robin': 轮询
|
||||
// - 'least-rooms': 最少房间数
|
||||
// - 'least-players': 最少玩家数
|
||||
// - 'random': 随机选择
|
||||
// - 'weighted': 权重(基于容量使用率)
|
||||
```
|
||||
|
||||
## 故障转移
|
||||
|
||||
当服务器离线时,启用 `enableFailover` 后系统会自动:
|
||||
|
||||
1. 检测到服务器离线(通过心跳超时)
|
||||
2. 查询该服务器上的所有房间
|
||||
3. 使用分布式锁防止多服务器同时恢复
|
||||
4. 从快照恢复房间状态
|
||||
5. 发布 `room:migrated` 事件通知其他服务器
|
||||
|
||||
```typescript
|
||||
// 确保定期保存快照
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'localhost',
|
||||
serverPort: 3000,
|
||||
snapshotInterval: 30000, // 每 30 秒保存快照
|
||||
enableFailover: true // 启用故障转移
|
||||
}, sendFn);
|
||||
```
|
||||
|
||||
## 后续版本
|
||||
|
||||
- Redis Cluster 支持
|
||||
- 更多负载均衡策略(地理位置、延迟感知)
|
||||
326
docs/src/content/docs/modules/network/fixed-point.md
Normal file
326
docs/src/content/docs/modules/network/fixed-point.md
Normal 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) - 定点数客户端预测
|
||||
679
docs/src/content/docs/modules/network/http.md
Normal file
679
docs/src/content/docs/modules/network/http.md
Normal file
@@ -0,0 +1,679 @@
|
||||
---
|
||||
title: "HTTP 路由"
|
||||
description: "HTTP REST API 路由功能,支持与 WebSocket 共用端口"
|
||||
---
|
||||
|
||||
`@esengine/server` 内置了轻量级的 HTTP 路由功能,可以与 WebSocket 服务共用同一端口,方便实现 REST API。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 内联路由定义
|
||||
|
||||
最简单的方式是在创建服务器时直接定义 HTTP 路由:
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server'
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
http: {
|
||||
'/api/health': (req, res) => {
|
||||
res.json({ status: 'ok', time: Date.now() })
|
||||
},
|
||||
'/api/users': {
|
||||
GET: (req, res) => {
|
||||
res.json({ users: [] })
|
||||
},
|
||||
POST: async (req, res) => {
|
||||
const body = req.body as { name: string }
|
||||
res.status(201).json({ id: '1', name: body.name })
|
||||
}
|
||||
}
|
||||
},
|
||||
cors: true // 启用 CORS
|
||||
})
|
||||
|
||||
await server.start()
|
||||
```
|
||||
|
||||
### 文件路由
|
||||
|
||||
对于较大的项目,推荐使用文件路由。创建 `src/http` 目录,每个文件对应一个路由:
|
||||
|
||||
```typescript
|
||||
// src/http/login.ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
interface LoginBody {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export default defineHttp<LoginBody>({
|
||||
method: 'POST',
|
||||
handler(req, res) {
|
||||
const { username, password } = req.body as LoginBody
|
||||
|
||||
// 验证用户...
|
||||
if (username === 'admin' && password === '123456') {
|
||||
res.json({ token: 'jwt-token-here', userId: 'user-1' })
|
||||
} else {
|
||||
res.error(401, '用户名或密码错误')
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
// server.ts
|
||||
import { createServer } from '@esengine/server'
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
httpDir: './src/http', // HTTP 路由目录
|
||||
httpPrefix: '/api', // 路由前缀
|
||||
cors: true
|
||||
})
|
||||
|
||||
await server.start()
|
||||
// 路由: POST /api/login
|
||||
```
|
||||
|
||||
## defineHttp 定义
|
||||
|
||||
`defineHttp` 用于定义类型安全的 HTTP 处理器:
|
||||
|
||||
```typescript
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
interface CreateUserBody {
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export default defineHttp<CreateUserBody>({
|
||||
// HTTP 方法(默认 POST)
|
||||
method: 'POST',
|
||||
|
||||
// 处理函数
|
||||
handler(req, res) {
|
||||
const body = req.body as CreateUserBody
|
||||
// 处理请求...
|
||||
res.status(201).json({ id: 'new-user-id' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 支持的 HTTP 方法
|
||||
|
||||
```typescript
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS'
|
||||
```
|
||||
|
||||
## HttpRequest 对象
|
||||
|
||||
HTTP 请求对象包含以下属性:
|
||||
|
||||
```typescript
|
||||
interface HttpRequest {
|
||||
/** 原始 Node.js IncomingMessage */
|
||||
raw: IncomingMessage
|
||||
|
||||
/** HTTP 方法 */
|
||||
method: string
|
||||
|
||||
/** 请求路径 */
|
||||
path: string
|
||||
|
||||
/** 路由参数(从 URL 路径提取,如 /users/:id) */
|
||||
params: Record<string, string>
|
||||
|
||||
/** 查询参数 */
|
||||
query: Record<string, string>
|
||||
|
||||
/** 请求头 */
|
||||
headers: Record<string, string | string[] | undefined>
|
||||
|
||||
/** 解析后的请求体 */
|
||||
body: unknown
|
||||
|
||||
/** 客户端 IP */
|
||||
ip: string
|
||||
}
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```typescript
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
// 获取查询参数
|
||||
const page = parseInt(req.query.page ?? '1')
|
||||
const limit = parseInt(req.query.limit ?? '10')
|
||||
|
||||
// 获取请求头
|
||||
const authHeader = req.headers.authorization
|
||||
|
||||
// 获取客户端 IP
|
||||
console.log('Request from:', req.ip)
|
||||
|
||||
res.json({ page, limit })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 请求体解析
|
||||
|
||||
请求体会根据 `Content-Type` 自动解析:
|
||||
|
||||
- `application/json` - 解析为 JSON 对象
|
||||
- `application/x-www-form-urlencoded` - 解析为键值对对象
|
||||
- 其他 - 保持原始字符串
|
||||
|
||||
```typescript
|
||||
export default defineHttp<{ name: string; age: number }>({
|
||||
method: 'POST',
|
||||
handler(req, res) {
|
||||
// body 已自动解析
|
||||
const { name, age } = req.body as { name: string; age: number }
|
||||
res.json({ received: { name, age } })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## HttpResponse 对象
|
||||
|
||||
HTTP 响应对象提供链式 API:
|
||||
|
||||
```typescript
|
||||
interface HttpResponse {
|
||||
/** 原始 Node.js ServerResponse */
|
||||
raw: ServerResponse
|
||||
|
||||
/** 设置状态码 */
|
||||
status(code: number): HttpResponse
|
||||
|
||||
/** 设置响应头 */
|
||||
header(name: string, value: string): HttpResponse
|
||||
|
||||
/** 发送 JSON 响应 */
|
||||
json(data: unknown): void
|
||||
|
||||
/** 发送文本响应 */
|
||||
text(data: string): void
|
||||
|
||||
/** 发送错误响应 */
|
||||
error(code: number, message: string): void
|
||||
}
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```typescript
|
||||
export default defineHttp({
|
||||
method: 'POST',
|
||||
handler(req, res) {
|
||||
// 设置状态码和自定义头
|
||||
res
|
||||
.status(201)
|
||||
.header('X-Custom-Header', 'value')
|
||||
.json({ created: true })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
// 发送错误响应
|
||||
res.error(404, '资源不存在')
|
||||
// 等价于: res.status(404).json({ error: '资源不存在' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
// 发送纯文本
|
||||
res.text('Hello, World!')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 文件路由规范
|
||||
|
||||
### 命名转换
|
||||
|
||||
文件名会自动转换为路由路径:
|
||||
|
||||
| 文件路径 | 路由路径(prefix=/api) |
|
||||
|---------|----------------------|
|
||||
| `login.ts` | `/api/login` |
|
||||
| `users/profile.ts` | `/api/users/profile` |
|
||||
| `users/[id].ts` | `/api/users/:id` |
|
||||
| `game/room/[roomId].ts` | `/api/game/room/:roomId` |
|
||||
|
||||
### 动态路由参数
|
||||
|
||||
使用 `[param]` 语法定义动态参数:
|
||||
|
||||
```typescript
|
||||
// src/http/users/[id].ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
// 直接从 params 获取路由参数
|
||||
const { id } = req.params
|
||||
res.json({ userId: id })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
多个参数的情况:
|
||||
|
||||
```typescript
|
||||
// src/http/users/[userId]/posts/[postId].ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
handler(req, res) {
|
||||
const { userId, postId } = req.params
|
||||
res.json({ userId, postId })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 跳过规则
|
||||
|
||||
以下文件会被自动跳过:
|
||||
|
||||
- 以 `_` 开头的文件(如 `_helper.ts`)
|
||||
- `index.ts` / `index.js` 文件
|
||||
- 非 `.ts` / `.js` / `.mts` / `.mjs` 文件
|
||||
|
||||
### 目录结构示例
|
||||
|
||||
```
|
||||
src/
|
||||
└── http/
|
||||
├── _utils.ts # 跳过(下划线开头)
|
||||
├── index.ts # 跳过(index 文件)
|
||||
├── health.ts # GET /api/health
|
||||
├── login.ts # POST /api/login
|
||||
├── register.ts # POST /api/register
|
||||
└── users/
|
||||
├── index.ts # 跳过
|
||||
├── list.ts # GET /api/users/list
|
||||
└── [id].ts # GET /api/users/:id
|
||||
```
|
||||
|
||||
## CORS 配置
|
||||
|
||||
### 快速启用
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
cors: true // 使用默认配置
|
||||
})
|
||||
```
|
||||
|
||||
### 自定义配置
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
cors: {
|
||||
// 允许的来源
|
||||
origin: ['http://localhost:5173', 'https://myapp.com'],
|
||||
// 或使用通配符
|
||||
// origin: '*',
|
||||
// origin: true, // 反射请求来源
|
||||
|
||||
// 允许的 HTTP 方法
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
|
||||
// 允许的请求头
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||
|
||||
// 是否允许携带凭证(cookies)
|
||||
credentials: true,
|
||||
|
||||
// 预检请求缓存时间(秒)
|
||||
maxAge: 86400
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### CorsOptions 类型
|
||||
|
||||
```typescript
|
||||
interface CorsOptions {
|
||||
/** 允许的来源:字符串、字符串数组、true(反射)或 '*' */
|
||||
origin?: string | string[] | boolean
|
||||
|
||||
/** 允许的 HTTP 方法 */
|
||||
methods?: string[]
|
||||
|
||||
/** 允许的请求头 */
|
||||
allowedHeaders?: string[]
|
||||
|
||||
/** 是否允许携带凭证 */
|
||||
credentials?: boolean
|
||||
|
||||
/** 预检请求缓存时间(秒) */
|
||||
maxAge?: number
|
||||
}
|
||||
```
|
||||
|
||||
## 路由合并
|
||||
|
||||
文件路由和内联路由可以同时使用,内联路由优先级更高:
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
httpDir: './src/http',
|
||||
httpPrefix: '/api',
|
||||
|
||||
// 内联路由会与文件路由合并
|
||||
http: {
|
||||
'/health': (req, res) => res.json({ status: 'ok' }),
|
||||
'/api/special': (req, res) => res.json({ special: true })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 与 WebSocket 共用端口
|
||||
|
||||
HTTP 路由与 WebSocket 服务自动共用同一端口:
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
// WebSocket 相关配置
|
||||
apiDir: './src/api',
|
||||
msgDir: './src/msg',
|
||||
|
||||
// HTTP 相关配置
|
||||
httpDir: './src/http',
|
||||
httpPrefix: '/api',
|
||||
cors: true
|
||||
})
|
||||
|
||||
await server.start()
|
||||
|
||||
// 同一端口 3000:
|
||||
// - WebSocket: ws://localhost:3000
|
||||
// - HTTP API: http://localhost:3000/api/*
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
### 游戏服务器登录 API
|
||||
|
||||
```typescript
|
||||
// src/http/auth/login.ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
import { createJwtAuthProvider } from '@esengine/server/auth'
|
||||
|
||||
interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
token: string
|
||||
userId: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const jwtProvider = createJwtAuthProvider({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600
|
||||
})
|
||||
|
||||
export default defineHttp<LoginRequest>({
|
||||
method: 'POST',
|
||||
async handler(req, res) {
|
||||
const { username, password } = req.body as LoginRequest
|
||||
|
||||
// 验证用户
|
||||
const user = await db.users.findByUsername(username)
|
||||
if (!user || !await verifyPassword(password, user.passwordHash)) {
|
||||
res.error(401, '用户名或密码错误')
|
||||
return
|
||||
}
|
||||
|
||||
// 生成 JWT
|
||||
const token = jwtProvider.sign({
|
||||
sub: user.id,
|
||||
name: user.username,
|
||||
roles: user.roles
|
||||
})
|
||||
|
||||
const response: LoginResponse = {
|
||||
token,
|
||||
userId: user.id,
|
||||
expiresAt: Date.now() + 3600 * 1000
|
||||
}
|
||||
|
||||
res.json(response)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 游戏数据查询 API
|
||||
|
||||
```typescript
|
||||
// src/http/game/leaderboard.ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
export default defineHttp({
|
||||
method: 'GET',
|
||||
async handler(req, res) {
|
||||
const limit = parseInt(req.query.limit ?? '10')
|
||||
const offset = parseInt(req.query.offset ?? '0')
|
||||
|
||||
const players = await db.players.findMany({
|
||||
sort: { score: 'desc' },
|
||||
limit,
|
||||
offset
|
||||
})
|
||||
|
||||
res.json({
|
||||
data: players,
|
||||
pagination: { limit, offset }
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 中间件
|
||||
|
||||
### 中间件类型
|
||||
|
||||
中间件是在路由处理前后执行的函数:
|
||||
|
||||
```typescript
|
||||
type HttpMiddleware = (
|
||||
req: HttpRequest,
|
||||
res: HttpResponse,
|
||||
next: () => Promise<void>
|
||||
) => void | Promise<void>
|
||||
```
|
||||
|
||||
### 内置中间件
|
||||
|
||||
```typescript
|
||||
import {
|
||||
requestLogger,
|
||||
bodyLimit,
|
||||
responseTime,
|
||||
requestId,
|
||||
securityHeaders
|
||||
} from '@esengine/server'
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
http: { /* ... */ },
|
||||
// 全局中间件通过 createHttpRouter 配置
|
||||
})
|
||||
```
|
||||
|
||||
#### requestLogger - 请求日志
|
||||
|
||||
```typescript
|
||||
import { requestLogger } from '@esengine/server'
|
||||
|
||||
// 记录请求和响应时间
|
||||
requestLogger()
|
||||
|
||||
// 同时记录请求体
|
||||
requestLogger({ logBody: true })
|
||||
```
|
||||
|
||||
#### bodyLimit - 请求体大小限制
|
||||
|
||||
```typescript
|
||||
import { bodyLimit } from '@esengine/server'
|
||||
|
||||
// 限制请求体为 1MB
|
||||
bodyLimit(1024 * 1024)
|
||||
```
|
||||
|
||||
#### responseTime - 响应时间头
|
||||
|
||||
```typescript
|
||||
import { responseTime } from '@esengine/server'
|
||||
|
||||
// 自动添加 X-Response-Time 响应头
|
||||
responseTime()
|
||||
```
|
||||
|
||||
#### requestId - 请求 ID
|
||||
|
||||
```typescript
|
||||
import { requestId } from '@esengine/server'
|
||||
|
||||
// 自动生成并添加 X-Request-ID 响应头
|
||||
requestId()
|
||||
|
||||
// 自定义头名称
|
||||
requestId('X-Trace-ID')
|
||||
```
|
||||
|
||||
#### securityHeaders - 安全头
|
||||
|
||||
```typescript
|
||||
import { securityHeaders } from '@esengine/server'
|
||||
|
||||
// 添加常用安全响应头
|
||||
securityHeaders()
|
||||
|
||||
// 自定义配置
|
||||
securityHeaders({
|
||||
hidePoweredBy: true,
|
||||
frameOptions: 'DENY',
|
||||
noSniff: true
|
||||
})
|
||||
```
|
||||
|
||||
### 自定义中间件
|
||||
|
||||
```typescript
|
||||
import type { HttpMiddleware } from '@esengine/server'
|
||||
|
||||
// 认证中间件
|
||||
const authMiddleware: HttpMiddleware = async (req, res, next) => {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '')
|
||||
|
||||
if (!token) {
|
||||
res.error(401, 'Unauthorized')
|
||||
return // 不调用 next(),终止请求
|
||||
}
|
||||
|
||||
// 验证 token...
|
||||
(req as any).userId = 'decoded-user-id'
|
||||
|
||||
await next() // 继续执行后续中间件和处理器
|
||||
}
|
||||
```
|
||||
|
||||
### 使用中间件
|
||||
|
||||
#### 使用 createHttpRouter
|
||||
|
||||
```typescript
|
||||
import { createHttpRouter, requestLogger, bodyLimit } from '@esengine/server'
|
||||
|
||||
const router = createHttpRouter({
|
||||
'/api/users': (req, res) => res.json([]),
|
||||
'/api/admin': {
|
||||
GET: {
|
||||
handler: (req, res) => res.json({ admin: true }),
|
||||
middlewares: [adminAuthMiddleware] // 路由级中间件
|
||||
}
|
||||
}
|
||||
}, {
|
||||
middlewares: [requestLogger(), bodyLimit(1024 * 1024)], // 全局中间件
|
||||
timeout: 30000 // 全局超时 30 秒
|
||||
})
|
||||
```
|
||||
|
||||
## 请求超时
|
||||
|
||||
### 全局超时
|
||||
|
||||
```typescript
|
||||
import { createHttpRouter } from '@esengine/server'
|
||||
|
||||
const router = createHttpRouter({
|
||||
'/api/data': async (req, res) => {
|
||||
// 如果处理超过 30 秒,自动返回 408 Request Timeout
|
||||
await someSlowOperation()
|
||||
res.json({ data: 'result' })
|
||||
}
|
||||
}, {
|
||||
timeout: 30000 // 30 秒
|
||||
})
|
||||
```
|
||||
|
||||
### 路由级超时
|
||||
|
||||
```typescript
|
||||
const router = createHttpRouter({
|
||||
'/api/quick': (req, res) => res.json({ fast: true }),
|
||||
|
||||
'/api/slow': {
|
||||
POST: {
|
||||
handler: async (req, res) => {
|
||||
await verySlowOperation()
|
||||
res.json({ done: true })
|
||||
},
|
||||
timeout: 120000 // 这个路由允许 2 分钟
|
||||
}
|
||||
}
|
||||
}, {
|
||||
timeout: 10000 // 全局 10 秒(被路由级覆盖)
|
||||
})
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用 defineHttp** - 获得更好的类型提示和代码组织
|
||||
2. **统一错误处理** - 使用 `res.error()` 返回一致的错误格式
|
||||
3. **启用 CORS** - 前后端分离时必须配置
|
||||
4. **目录组织** - 按功能模块组织 HTTP 路由文件
|
||||
5. **验证输入** - 始终验证 `req.body` 和 `req.query` 的内容
|
||||
6. **状态码规范** - 遵循 HTTP 状态码规范(200、201、400、401、404、500 等)
|
||||
7. **使用中间件** - 通过中间件实现认证、日志、限流等横切关注点
|
||||
8. **设置超时** - 避免慢请求阻塞服务器
|
||||
@@ -147,6 +147,7 @@ service.on('chat', (data) => {
|
||||
|
||||
- [客户端使用](/modules/network/client/) - NetworkPlugin、组件和系统
|
||||
- [服务器端](/modules/network/server/) - GameServer 和 Room 管理
|
||||
- [分布式房间](/modules/network/distributed/) - 多服务器房间管理和玩家路由
|
||||
- [状态同步](/modules/network/sync/) - 插值和快照缓冲
|
||||
- [客户端预测](/modules/network/prediction/) - 输入预测和服务器校正
|
||||
- [兴趣区域 (AOI)](/modules/network/aoi/) - 视野过滤和带宽优化
|
||||
|
||||
@@ -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';
|
||||
```
|
||||
|
||||
@@ -79,10 +79,47 @@ await server.start()
|
||||
| `tickRate` | `number` | `20` | 全局 Tick 频率 (Hz) |
|
||||
| `apiDir` | `string` | `'src/api'` | API 处理器目录 |
|
||||
| `msgDir` | `string` | `'src/msg'` | 消息处理器目录 |
|
||||
| `httpDir` | `string` | `'src/http'` | HTTP 路由目录 |
|
||||
| `httpPrefix` | `string` | `'/api'` | HTTP 路由前缀 |
|
||||
| `cors` | `boolean \| CorsOptions` | - | CORS 配置 |
|
||||
| `onStart` | `(port) => void` | - | 启动回调 |
|
||||
| `onConnect` | `(conn) => void` | - | 连接回调 |
|
||||
| `onDisconnect` | `(conn) => void` | - | 断开回调 |
|
||||
|
||||
## HTTP 路由
|
||||
|
||||
支持 HTTP API 与 WebSocket 共用端口,适用于登录、注册等场景。
|
||||
|
||||
```typescript
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
httpDir: './src/http', // HTTP 路由目录
|
||||
httpPrefix: '/api', // 路由前缀
|
||||
cors: true,
|
||||
|
||||
// 或内联定义
|
||||
http: {
|
||||
'/health': (req, res) => res.json({ status: 'ok' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/http/login.ts
|
||||
import { defineHttp } from '@esengine/server'
|
||||
|
||||
export default defineHttp<{ username: string; password: string }>({
|
||||
method: 'POST',
|
||||
handler(req, res) {
|
||||
const { username, password } = req.body
|
||||
// 验证并返回 token...
|
||||
res.json({ token: '...' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
> 详细文档请参考 [HTTP 路由](/modules/network/http)
|
||||
|
||||
## Room 系统
|
||||
|
||||
Room 是游戏房间的基类,管理玩家和游戏状态。
|
||||
@@ -243,6 +280,122 @@ class GameRoom extends Room {
|
||||
}
|
||||
```
|
||||
|
||||
## Schema 验证
|
||||
|
||||
使用内置的 Schema 验证系统进行运行时类型验证:
|
||||
|
||||
### 基础用法
|
||||
|
||||
```typescript
|
||||
import { s, defineApiWithSchema } from '@esengine/server'
|
||||
|
||||
// 定义 Schema
|
||||
const MoveSchema = s.object({
|
||||
x: s.number(),
|
||||
y: s.number(),
|
||||
speed: s.number().optional()
|
||||
})
|
||||
|
||||
// 类型自动推断
|
||||
type Move = s.infer<typeof MoveSchema> // { x: number; y: number; speed?: number }
|
||||
|
||||
// 使用 Schema 定义 API(自动验证)
|
||||
export default defineApiWithSchema(MoveSchema, {
|
||||
handler(req, ctx) {
|
||||
// req 已验证,类型安全
|
||||
console.log(req.x, req.y)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 验证器类型
|
||||
|
||||
| 类型 | 示例 | 描述 |
|
||||
|------|------|------|
|
||||
| `s.string()` | `s.string().min(1).max(50)` | 字符串,支持长度限制 |
|
||||
| `s.number()` | `s.number().min(0).int()` | 数字,支持范围和整数限制 |
|
||||
| `s.boolean()` | `s.boolean()` | 布尔值 |
|
||||
| `s.literal()` | `s.literal('admin')` | 字面量类型 |
|
||||
| `s.object()` | `s.object({ name: s.string() })` | 对象 |
|
||||
| `s.array()` | `s.array(s.number())` | 数组 |
|
||||
| `s.enum()` | `s.enum(['a', 'b'] as const)` | 枚举 |
|
||||
| `s.union()` | `s.union([s.string(), s.number()])` | 联合类型 |
|
||||
| `s.record()` | `s.record(s.any())` | 记录类型 |
|
||||
|
||||
### 修饰符
|
||||
|
||||
```typescript
|
||||
// 可选字段
|
||||
s.string().optional()
|
||||
|
||||
// 默认值
|
||||
s.number().default(0)
|
||||
|
||||
// 可为 null
|
||||
s.string().nullable()
|
||||
|
||||
// 字符串验证
|
||||
s.string().min(1).max(100).email().url().regex(/^[a-z]+$/)
|
||||
|
||||
// 数字验证
|
||||
s.number().min(0).max(100).int().positive()
|
||||
|
||||
// 数组验证
|
||||
s.array(s.string()).min(1).max(10).nonempty()
|
||||
|
||||
// 对象验证
|
||||
s.object({ ... }).strict() // 不允许额外字段
|
||||
s.object({ ... }).partial() // 所有字段可选
|
||||
s.object({ ... }).pick('name', 'age') // 选择字段
|
||||
s.object({ ... }).omit('password') // 排除字段
|
||||
```
|
||||
|
||||
### 消息验证
|
||||
|
||||
```typescript
|
||||
import { s, defineMsgWithSchema } from '@esengine/server'
|
||||
|
||||
const InputSchema = s.object({
|
||||
keys: s.array(s.string()),
|
||||
timestamp: s.number()
|
||||
})
|
||||
|
||||
export default defineMsgWithSchema(InputSchema, {
|
||||
handler(msg, ctx) {
|
||||
// msg 已验证
|
||||
console.log(msg.keys, msg.timestamp)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 手动验证
|
||||
|
||||
```typescript
|
||||
import { s, parse, safeParse, createGuard } from '@esengine/server'
|
||||
|
||||
const UserSchema = s.object({
|
||||
name: s.string(),
|
||||
age: s.number().int().min(0)
|
||||
})
|
||||
|
||||
// 抛出错误
|
||||
const user = parse(UserSchema, data)
|
||||
|
||||
// 返回结果对象
|
||||
const result = safeParse(UserSchema, data)
|
||||
if (result.success) {
|
||||
console.log(result.data)
|
||||
} else {
|
||||
console.error(result.error)
|
||||
}
|
||||
|
||||
// 类型守卫
|
||||
const isUser = createGuard(UserSchema)
|
||||
if (isUser(data)) {
|
||||
// data 是 User 类型
|
||||
}
|
||||
```
|
||||
|
||||
## 协议定义
|
||||
|
||||
在 `src/shared/protocol.ts` 中定义客户端和服务端共享的类型:
|
||||
|
||||
@@ -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';
|
||||
```
|
||||
|
||||
@@ -125,23 +125,24 @@ tx:data:{key} - 业务数据
|
||||
|
||||
## MongoStorage
|
||||
|
||||
MongoDB 存储,适用于需要持久化和复杂查询的场景。使用工厂模式实现惰性连接。
|
||||
MongoDB 存储,适用于需要持久化和复杂查询的场景。使用 `@esengine/database-drivers` 的共享连接。
|
||||
|
||||
```typescript
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { MongoStorage } from '@esengine/transaction';
|
||||
import { createMongoConnection } from '@esengine/database-drivers';
|
||||
import { createMongoStorage, TransactionManager } from '@esengine/transaction';
|
||||
|
||||
// 工厂模式:惰性连接,首次操作时才创建连接
|
||||
const storage = new MongoStorage({
|
||||
factory: async () => {
|
||||
const client = new MongoClient('mongodb://localhost:27017');
|
||||
await client.connect();
|
||||
return client;
|
||||
},
|
||||
database: 'game',
|
||||
transactionCollection: 'transactions', // 事务日志集合
|
||||
dataCollection: 'transaction_data', // 业务数据集合
|
||||
lockCollection: 'transaction_locks', // 锁集合
|
||||
// 创建共享连接
|
||||
const mongo = createMongoConnection({
|
||||
uri: 'mongodb://localhost:27017',
|
||||
database: 'game'
|
||||
});
|
||||
await mongo.connect();
|
||||
|
||||
// 使用共享连接创建存储
|
||||
const storage = createMongoStorage(mongo, {
|
||||
transactionCollection: 'transactions', // 事务日志集合(可选)
|
||||
dataCollection: 'transaction_data', // 业务数据集合(可选)
|
||||
lockCollection: 'transaction_locks', // 锁集合(可选)
|
||||
});
|
||||
|
||||
// 创建索引(首次运行时执行)
|
||||
@@ -149,11 +150,14 @@ await storage.ensureIndexes();
|
||||
|
||||
const manager = new TransactionManager({ storage });
|
||||
|
||||
// 使用后关闭连接
|
||||
// 关闭存储(不会关闭共享连接)
|
||||
await storage.close();
|
||||
|
||||
// 或使用 await using 自动关闭 (TypeScript 5.2+)
|
||||
await using storage = new MongoStorage({ ... });
|
||||
// 共享连接可继续用于其他模块
|
||||
const userRepo = new UserRepository(mongo); // @esengine/database
|
||||
|
||||
// 最后关闭共享连接
|
||||
await mongo.disconnect();
|
||||
```
|
||||
|
||||
### 特点
|
||||
|
||||
@@ -236,3 +236,257 @@ nav.sidebar-content ul li a[aria-current="page"] {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== Blueprint Node Visualization ==================== */
|
||||
/* Matches the actual node-editor component styles */
|
||||
|
||||
/* Graph Container - 使用固定像素坐标 */
|
||||
.bp-graph {
|
||||
position: relative;
|
||||
background: #141419;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
border-radius: 8px;
|
||||
margin: 1.5rem 0;
|
||||
border: 1px solid #2a2a35;
|
||||
overflow: auto;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.bp-graph:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Inner container for panning */
|
||||
.bp-graph-content {
|
||||
position: relative;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
/* SVG Connections Layer */
|
||||
.bp-connections {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Node absolute positioning in graph */
|
||||
.bp-graph > .bp-node {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* Connection paths */
|
||||
.bp-conn {
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.bp-conn.exec { stroke: #ffffff; stroke-width: 3px; }
|
||||
.bp-conn.float { stroke: #7ecd32; }
|
||||
.bp-conn.int { stroke: #1cc4c4; }
|
||||
.bp-conn.bool { stroke: #8c0000; }
|
||||
.bp-conn.string { stroke: #e060e0; }
|
||||
.bp-conn.object { stroke: #00a0e0; }
|
||||
.bp-conn.entity { stroke: #00a0e0; }
|
||||
.bp-conn.component { stroke: #7030c0; }
|
||||
.bp-conn.array { stroke: #7030c0; }
|
||||
.bp-conn.any { stroke: #707070; }
|
||||
|
||||
/* ==================== Node Container ==================== */
|
||||
.bp-node {
|
||||
position: relative;
|
||||
min-width: 140px;
|
||||
background: rgba(12, 12, 16, 0.95);
|
||||
border: 1px solid rgba(40, 40, 50, 0.8);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ==================== Node Header ==================== */
|
||||
/* 与 node-editor 完全一致 */
|
||||
.bp-node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
min-height: 28px;
|
||||
border-radius: 5px 5px 0 0;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Header icon - diamond for events */
|
||||
.bp-node-header-icon {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bp-node-header.event .bp-node-header-icon {
|
||||
background: #fff;
|
||||
transform: rotate(45deg);
|
||||
border-radius: 1px;
|
||||
box-shadow: 0 0 4px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Header title */
|
||||
.bp-node-header-title {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Header exec pin (for event nodes) - at right edge */
|
||||
/* 与 node-editor 完全一致 */
|
||||
.bp-header-exec {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bp-header-exec svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Category Colors */
|
||||
.bp-node-header.event {
|
||||
background: linear-gradient(180deg, #b81c1c 0%, #6b1010 100%);
|
||||
}
|
||||
|
||||
.bp-node-header.function {
|
||||
background: linear-gradient(180deg, #1b6eb5 0%, #0d3d66 100%);
|
||||
}
|
||||
|
||||
.bp-node-header.pure {
|
||||
background: linear-gradient(180deg, #3d8b3d 0%, #1f5a1f 100%);
|
||||
}
|
||||
|
||||
.bp-node-header.flow {
|
||||
background: linear-gradient(180deg, #4a4a4a 0%, #2a2a2a 100%);
|
||||
}
|
||||
|
||||
.bp-node-header.variable {
|
||||
background: linear-gradient(180deg, #7b3d9b 0%, #4a1f66 100%);
|
||||
}
|
||||
|
||||
.bp-node-header.debug {
|
||||
background: linear-gradient(180deg, #6a6a6a 0%, #3a3a3a 100%);
|
||||
}
|
||||
|
||||
.bp-node-header.time {
|
||||
background: linear-gradient(180deg, #1cc4c4 0%, #0d7070 100%);
|
||||
}
|
||||
|
||||
.bp-node-header.math {
|
||||
background: linear-gradient(180deg, #7ecd32 0%, #4a7a1e 100%);
|
||||
}
|
||||
|
||||
/* ==================== Node Body ==================== */
|
||||
/* 与 node-editor 完全一致 */
|
||||
.bp-node-body {
|
||||
padding: 6px 0;
|
||||
background: rgba(8, 8, 12, 0.95);
|
||||
border-radius: 0 0 5px 5px;
|
||||
}
|
||||
|
||||
/* ==================== Pin Row ==================== */
|
||||
/* 与 node-editor 完全一致 */
|
||||
.bp-pin-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 20px;
|
||||
padding: 1px 0;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bp-pin-row.input {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
padding-left: 10px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.bp-pin-row.output {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
padding-right: 10px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
/* ==================== Pin (SVG-based) ==================== */
|
||||
.bp-pin {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bp-pin svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Pin Label */
|
||||
.bp-pin-label {
|
||||
color: #b0b0b0;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bp-pin-row.output .bp-pin-label {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Pin Value */
|
||||
.bp-pin-value {
|
||||
color: #7ecd32;
|
||||
font-size: 10px;
|
||||
margin-left: auto;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.bp-pin-row.output .bp-pin-value {
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* ==================== Legend ==================== */
|
||||
.bp-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
background: rgba(20, 20, 25, 0.8);
|
||||
border-radius: 6px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.bp-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
Submodule examples/lawn-mower-demo updated: 5a4976b192...3f0695f59b
@@ -13,6 +13,7 @@
|
||||
"packages/network-ext/*",
|
||||
"packages/editor/*",
|
||||
"packages/editor/plugins/*",
|
||||
"packages/devtools/*",
|
||||
"packages/rust/*",
|
||||
"packages/tools/*"
|
||||
],
|
||||
@@ -74,6 +75,7 @@
|
||||
"lint:fix": "turbo run lint:fix",
|
||||
"build:wasm": "cd packages/rust/engine && wasm-pack build --dev --out-dir pkg",
|
||||
"build:wasm:release": "cd packages/rust/engine && wasm-pack build --release --out-dir pkg",
|
||||
"build:rapier2d": "node scripts/build-rapier2d.mjs",
|
||||
"copy-modules": "node scripts/copy-engine-modules.mjs"
|
||||
},
|
||||
"author": "yhh",
|
||||
|
||||
75
packages/devtools/node-editor/CHANGELOG.md
Normal file
75
packages/devtools/node-editor/CHANGELOG.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# @esengine/node-editor
|
||||
|
||||
## 1.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#438](https://github.com/esengine/esengine/pull/438) [`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b) Thanks [@esengine](https://github.com/esengine)! - feat(node-editor): add visual group box for organizing nodes
|
||||
- Add NodeGroup model with dynamic bounds calculation based on node pin counts
|
||||
- Add GroupNodeComponent for rendering group boxes behind nodes
|
||||
- Groups automatically resize to wrap contained nodes
|
||||
- Dragging group header moves all nodes inside together
|
||||
- Support group serialization/deserialization
|
||||
- Export `estimateNodeHeight` and `NodeBounds` for accurate size calculation
|
||||
|
||||
feat(blueprint): add comprehensive math and logic nodes
|
||||
|
||||
Math nodes:
|
||||
- Modulo, Abs, Min, Max, Power, Sqrt
|
||||
- Floor, Ceil, Round, Sign, Negate
|
||||
- Sin, Cos, Tan, Asin, Acos, Atan, Atan2
|
||||
- DegToRad, RadToDeg, Lerp, InverseLerp
|
||||
- Clamp, Wrap, RandomRange, RandomInt
|
||||
|
||||
Logic nodes:
|
||||
- Equal, NotEqual, GreaterThan, GreaterThanOrEqual
|
||||
- LessThan, LessThanOrEqual, InRange
|
||||
- AND, OR, NOT, XOR, NAND
|
||||
- IsNull, Select (ternary)
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [`3bfb8a1`](https://github.com/esengine/esengine/commit/3bfb8a1c9baba18373717910d29f266a71c1f63e) Thanks [@esengine](https://github.com/esengine)! - feat(node-editor): add box selection and variable node error states
|
||||
- Add box selection: drag on empty canvas to select multiple nodes
|
||||
- Support Ctrl+drag for additive selection (add to existing selection)
|
||||
- Add error state styling for invalid variable references (red border, warning icon)
|
||||
- Support dynamic node title via `data.displayTitle`
|
||||
- Support hiding inputs via `data.hiddenInputs` array
|
||||
|
||||
## 1.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#435](https://github.com/esengine/esengine/pull/435) [`c2acd14`](https://github.com/esengine/esengine/commit/c2acd14fce83af6cd116b3f2e40607229ccc3d6e) Thanks [@esengine](https://github.com/esengine)! - fix(node-editor): 修复节点收缩后连线不显示的问题
|
||||
- 节点收缩时,连线会连接到节点头部(输入引脚在左侧,输出引脚在右侧)
|
||||
- 展开后连线会自动恢复到正确位置
|
||||
|
||||
## 1.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#433](https://github.com/esengine/esengine/pull/433) [`2e84942`](https://github.com/esengine/esengine/commit/2e84942ea14c5326620398add05840fa8bea16f8) Thanks [@esengine](https://github.com/esengine)! - fix(node-editor): 修复节点收缩后连线不显示的问题
|
||||
- 节点收缩时,连线会连接到节点头部(输入引脚在左侧,输出引脚在右侧)
|
||||
- 展开后连线会自动恢复到正确位置
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#430](https://github.com/esengine/esengine/pull/430) [`caf3be7`](https://github.com/esengine/esengine/commit/caf3be72cdcc730492c63abe5f1715893f3579ac) Thanks [@esengine](https://github.com/esengine)! - feat(node-editor): 添加 Shadow DOM 样式注入支持 | Add Shadow DOM style injection support
|
||||
|
||||
**@esengine/node-editor**
|
||||
- 新增 `nodeEditorCssText` 导出,包含所有编辑器样式的 CSS 文本 | Added `nodeEditorCssText` export containing all editor styles as CSS text
|
||||
- 新增 `injectNodeEditorStyles(root)` 函数,支持将样式注入到 Shadow DOM | Added `injectNodeEditorStyles(root)` function for injecting styles into Shadow DOM
|
||||
- 支持在 Cocos Creator 等使用 Shadow DOM 的环境中使用 | Support usage in Shadow DOM environments like Cocos Creator
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#426](https://github.com/esengine/esengine/pull/426) [`6970394`](https://github.com/esengine/esengine/commit/6970394717ab8f743b0a41e248e3404a3b6fc7dc) Thanks [@esengine](https://github.com/esengine)! - feat: 独立发布节点编辑器 | Standalone node editor release
|
||||
- 移动到 packages/devtools 目录 | Move to packages/devtools directory
|
||||
- 清理依赖,使包可独立使用 | Clean dependencies for standalone use
|
||||
- 可用于 Cocos Creator / LayaAir 插件开发 | Available for Cocos/Laya plugin development
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/node-editor",
|
||||
"version": "1.0.0",
|
||||
"version": "1.4.0",
|
||||
"description": "Universal node-based visual editor for blueprint, shader graph, and state machine",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
@@ -9,7 +9,8 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./styles": {
|
||||
"import": "./dist/styles/index.css"
|
||||
@@ -30,17 +31,18 @@
|
||||
"blueprint",
|
||||
"shader-graph",
|
||||
"state-machine",
|
||||
"ecs",
|
||||
"game-engine"
|
||||
"react"
|
||||
],
|
||||
"author": "yhh",
|
||||
"author": "ESEngine Team",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"react": "^18.3.1",
|
||||
"zustand": "^5.0.8",
|
||||
"@types/node": "^20.19.17",
|
||||
"@types/react": "^18.3.12",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"react": "^18.3.1",
|
||||
"rimraf": "^5.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.0.7",
|
||||
@@ -56,7 +58,6 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/esengine/esengine.git",
|
||||
"directory": "packages/node-editor"
|
||||
},
|
||||
"private": true
|
||||
"directory": "packages/devtools/node-editor"
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useRef, useCallback, useState, useMemo } from 'react';
|
||||
import React, { useRef, useCallback, useState, useMemo, useEffect } from 'react';
|
||||
import { Graph } from '../../domain/models/Graph';
|
||||
import { GraphNode, NodeTemplate } from '../../domain/models/GraphNode';
|
||||
import { Connection } from '../../domain/models/Connection';
|
||||
import { Pin } from '../../domain/models/Pin';
|
||||
import { Position } from '../../domain/value-objects/Position';
|
||||
import { NodeGroup, computeGroupBounds, estimateNodeHeight } from '../../domain/models/NodeGroup';
|
||||
import { GraphCanvas } from '../canvas/GraphCanvas';
|
||||
import { MemoizedGraphNodeComponent, NodeExecutionState } from '../nodes/GraphNodeComponent';
|
||||
import { MemoizedGroupNodeComponent } from '../nodes/GroupNodeComponent';
|
||||
import { ConnectionLayer } from '../connections/ConnectionLine';
|
||||
|
||||
/**
|
||||
@@ -56,6 +58,12 @@ export interface NodeEditorProps {
|
||||
|
||||
/** Connection context menu callback (连接右键菜单回调) */
|
||||
onConnectionContextMenu?: (connection: Connection, e: React.MouseEvent) => void;
|
||||
|
||||
/** Group context menu callback (组右键菜单回调) */
|
||||
onGroupContextMenu?: (group: NodeGroup, e: React.MouseEvent) => void;
|
||||
|
||||
/** Group double click callback - typically used to expand group (组双击回调 - 通常用于展开组) */
|
||||
onGroupDoubleClick?: (group: NodeGroup) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,6 +88,16 @@ interface ConnectionDragState {
|
||||
isValid?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Box selection state
|
||||
* 框选状态
|
||||
*/
|
||||
interface BoxSelectState {
|
||||
startPos: Position;
|
||||
currentPos: Position;
|
||||
additive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* NodeEditor - Complete node graph editor component
|
||||
* NodeEditor - 完整的节点图编辑器组件
|
||||
@@ -102,7 +120,9 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
onNodeDoubleClick: _onNodeDoubleClick,
|
||||
onCanvasContextMenu,
|
||||
onNodeContextMenu,
|
||||
onConnectionContextMenu
|
||||
onConnectionContextMenu,
|
||||
onGroupContextMenu,
|
||||
onGroupDoubleClick
|
||||
}) => {
|
||||
// Silence unused variable warnings (消除未使用变量警告)
|
||||
void _templates;
|
||||
@@ -126,6 +146,88 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
const [dragState, setDragState] = useState<DragState | null>(null);
|
||||
const [connectionDrag, setConnectionDrag] = useState<ConnectionDragState | null>(null);
|
||||
const [hoveredPin, setHoveredPin] = useState<Pin | null>(null);
|
||||
const [boxSelectState, setBoxSelectState] = useState<BoxSelectState | null>(null);
|
||||
|
||||
// Track if box selection just ended to prevent click from clearing selection
|
||||
// 跟踪框选是否刚刚结束,以防止 click 清除选择
|
||||
const boxSelectJustEndedRef = useRef(false);
|
||||
|
||||
// Force re-render after mount to ensure connections are drawn correctly
|
||||
// 挂载后强制重渲染以确保连接线正确绘制
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
// Track collapsed state to force connection re-render
|
||||
// 跟踪折叠状态以强制连接线重渲染
|
||||
const collapsedNodesKey = useMemo(() => {
|
||||
return graph.nodes.map(n => `${n.id}:${n.isCollapsed}`).join(',');
|
||||
}, [graph.nodes]);
|
||||
|
||||
// Groups are now simple visual boxes - no node hiding
|
||||
// 组现在是简单的可视化框 - 不隐藏节点
|
||||
|
||||
// Track selected group IDs (local state, managed similarly to nodes)
|
||||
// 跟踪选中的组ID(本地状态,类似节点管理方式)
|
||||
const [selectedGroupIds, setSelectedGroupIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Group drag state - includes initial positions of nodes in the group
|
||||
// 组拖拽状态 - 包含组内节点的初始位置
|
||||
const [groupDragState, setGroupDragState] = useState<{
|
||||
groupId: string;
|
||||
startGroupPosition: Position;
|
||||
startMouse: { x: number; y: number };
|
||||
nodeStartPositions: Map<string, Position>;
|
||||
} | null>(null);
|
||||
|
||||
// Key for tracking group changes
|
||||
const groupsKey = useMemo(() => {
|
||||
return graph.groups.map(g => `${g.id}:${g.position.x}:${g.position.y}`).join(',');
|
||||
}, [graph.groups]);
|
||||
|
||||
// Compute dynamic group bounds based on current node positions and sizes
|
||||
// 根据当前节点位置和尺寸动态计算组边界
|
||||
const groupsWithDynamicBounds = useMemo(() => {
|
||||
const defaultNodeWidth = 200;
|
||||
|
||||
return graph.groups.map(group => {
|
||||
// Get current bounds of all nodes in this group
|
||||
const nodeBounds = group.nodeIds
|
||||
.map(nodeId => graph.getNode(nodeId))
|
||||
.filter((node): node is GraphNode => node !== undefined)
|
||||
.map(node => ({
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
width: defaultNodeWidth,
|
||||
height: estimateNodeHeight(
|
||||
node.inputPins.length,
|
||||
node.outputPins.length,
|
||||
node.isCollapsed
|
||||
)
|
||||
}));
|
||||
|
||||
if (nodeBounds.length === 0) {
|
||||
// No nodes found, use stored position/size as fallback
|
||||
return group;
|
||||
}
|
||||
|
||||
// Calculate dynamic bounds based on actual node sizes
|
||||
const { position, size } = computeGroupBounds(nodeBounds);
|
||||
|
||||
return {
|
||||
...group,
|
||||
position,
|
||||
size
|
||||
};
|
||||
});
|
||||
}, [graph.groups, graph.nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
// Use requestAnimationFrame to wait for DOM to be fully rendered
|
||||
// 使用 requestAnimationFrame 等待 DOM 完全渲染
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
forceUpdate(n => n + 1);
|
||||
});
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [graph.id, collapsedNodesKey, groupsKey]);
|
||||
|
||||
/**
|
||||
* Converts screen coordinates to canvas coordinates
|
||||
@@ -146,21 +248,52 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
* 获取引脚在画布坐标系中的位置
|
||||
*
|
||||
* 直接从节点位置和引脚在节点内的相对位置计算,不依赖 DOM 测量
|
||||
* 当节点收缩时,返回节点头部的位置
|
||||
* 当节点在折叠组中时,返回组节点的位置
|
||||
*/
|
||||
const getPinPosition = useCallback((pinId: string): Position | undefined => {
|
||||
// First, find which node this pin belongs to
|
||||
// 首先查找该引脚属于哪个节点
|
||||
let ownerNode: GraphNode | undefined;
|
||||
for (const node of graph.nodes) {
|
||||
if (node.allPins.some(p => p.id === pinId)) {
|
||||
ownerNode = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ownerNode) return undefined;
|
||||
|
||||
// Find the pin element and its parent node
|
||||
const pinElement = containerRef.current?.querySelector(`[data-pin-id="${pinId}"]`) as HTMLElement;
|
||||
if (!pinElement) return undefined;
|
||||
|
||||
// If pin element not found (e.g., node is collapsed), use node header position
|
||||
// 如果找不到引脚元素(例如节点已收缩),使用节点头部位置
|
||||
if (!pinElement) {
|
||||
const nodeElement = containerRef.current?.querySelector(`[data-node-id="${ownerNode.id}"]`) as HTMLElement;
|
||||
if (!nodeElement) return undefined;
|
||||
|
||||
const nodeRect = nodeElement.getBoundingClientRect();
|
||||
const { zoom } = transformRef.current;
|
||||
|
||||
// Find the pin to determine if it's input or output
|
||||
const pin = ownerNode.allPins.find(p => p.id === pinId);
|
||||
const isOutput = pin?.isOutput ?? false;
|
||||
|
||||
// For collapsed nodes, position at the right side for outputs, left side for inputs
|
||||
// 对于收缩的节点,输出引脚在右侧,输入引脚在左侧
|
||||
const headerHeight = 28; // Approximate header height
|
||||
const relativeX = isOutput ? nodeRect.width / zoom : 0;
|
||||
const relativeY = headerHeight / 2;
|
||||
|
||||
return new Position(
|
||||
ownerNode.position.x + relativeX,
|
||||
ownerNode.position.y + relativeY
|
||||
);
|
||||
}
|
||||
|
||||
const nodeElement = pinElement.closest('[data-node-id]') as HTMLElement;
|
||||
if (!nodeElement) return undefined;
|
||||
|
||||
const nodeId = nodeElement.getAttribute('data-node-id');
|
||||
if (!nodeId) return undefined;
|
||||
|
||||
const node = graph.getNode(nodeId);
|
||||
if (!node) return undefined;
|
||||
|
||||
// Get pin position relative to node element (in unscaled pixels)
|
||||
const nodeRect = nodeElement.getBoundingClientRect();
|
||||
const pinRect = pinElement.getBoundingClientRect();
|
||||
@@ -172,8 +305,8 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
|
||||
// Final position = node position + relative position
|
||||
return new Position(
|
||||
node.position.x + relativeX,
|
||||
node.position.y + relativeY
|
||||
ownerNode.position.x + relativeX,
|
||||
ownerNode.position.y + relativeY
|
||||
);
|
||||
}, [graph]);
|
||||
|
||||
@@ -255,6 +388,22 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
onGraphChange?.(newGraph);
|
||||
}
|
||||
|
||||
// Group dragging - moves all nodes inside (group bounds are dynamic)
|
||||
// 组拖拽 - 移动组内所有节点(组边界是动态计算的)
|
||||
if (groupDragState) {
|
||||
const dx = mousePos.x - groupDragState.startMouse.x;
|
||||
const dy = mousePos.y - groupDragState.startMouse.y;
|
||||
|
||||
// Only move nodes - group bounds will auto-recalculate
|
||||
let newGraph = graph;
|
||||
for (const [nodeId, startPos] of groupDragState.nodeStartPositions) {
|
||||
const newNodePos = new Position(startPos.x + dx, startPos.y + dy);
|
||||
newGraph = newGraph.moveNode(nodeId, newNodePos);
|
||||
}
|
||||
|
||||
onGraphChange?.(newGraph);
|
||||
}
|
||||
|
||||
// Connection dragging (连接拖拽)
|
||||
if (connectionDrag) {
|
||||
const isValid = hoveredPin ? connectionDrag.fromPin.canConnectTo(hoveredPin) : undefined;
|
||||
@@ -266,7 +415,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
isValid
|
||||
} : null);
|
||||
}
|
||||
}, [graph, dragState, connectionDrag, hoveredPin, screenToCanvas, onGraphChange]);
|
||||
}, [graph, dragState, groupDragState, connectionDrag, hoveredPin, screenToCanvas, onGraphChange]);
|
||||
|
||||
/**
|
||||
* Handles mouse up to end dragging
|
||||
@@ -278,6 +427,11 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
setDragState(null);
|
||||
}
|
||||
|
||||
// End group dragging (结束组拖拽)
|
||||
if (groupDragState) {
|
||||
setGroupDragState(null);
|
||||
}
|
||||
|
||||
// End connection dragging (结束连接拖拽)
|
||||
if (connectionDrag) {
|
||||
// Use hoveredPin directly instead of relying on async state update
|
||||
@@ -312,7 +466,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
|
||||
setConnectionDrag(null);
|
||||
}
|
||||
}, [graph, dragState, connectionDrag, hoveredPin, onGraphChange]);
|
||||
}, [graph, dragState, groupDragState, connectionDrag, hoveredPin, onGraphChange]);
|
||||
|
||||
/**
|
||||
* Handles pin mouse down
|
||||
@@ -423,13 +577,95 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
}
|
||||
}, [graph, onConnectionContextMenu]);
|
||||
|
||||
/**
|
||||
* Handles group selection
|
||||
* 处理组选择
|
||||
*/
|
||||
const handleGroupSelect = useCallback((groupId: string, additive: boolean) => {
|
||||
if (readOnly) return;
|
||||
|
||||
const newSelection = new Set(selectedGroupIds);
|
||||
|
||||
if (additive) {
|
||||
if (newSelection.has(groupId)) {
|
||||
newSelection.delete(groupId);
|
||||
} else {
|
||||
newSelection.add(groupId);
|
||||
}
|
||||
} else {
|
||||
newSelection.clear();
|
||||
newSelection.add(groupId);
|
||||
}
|
||||
|
||||
setSelectedGroupIds(newSelection);
|
||||
// Clear node and connection selection when selecting groups
|
||||
onSelectionChange?.(new Set(), new Set());
|
||||
}, [selectedGroupIds, readOnly, onSelectionChange]);
|
||||
|
||||
/**
|
||||
* Handles group drag start
|
||||
* 处理组拖拽开始
|
||||
*
|
||||
* Captures initial positions of both the group and all nodes inside it
|
||||
* 捕获组和组内所有节点的初始位置
|
||||
*/
|
||||
const handleGroupDragStart = useCallback((groupId: string, startMouse: { x: number; y: number }) => {
|
||||
if (readOnly) return;
|
||||
|
||||
const group = graph.getGroup(groupId);
|
||||
if (!group) return;
|
||||
|
||||
// Convert screen coordinates to canvas coordinates (same as node dragging)
|
||||
// 将屏幕坐标转换为画布坐标(与节点拖拽相同)
|
||||
const canvasPos = screenToCanvas(startMouse.x, startMouse.y);
|
||||
|
||||
// Capture initial positions of all nodes in the group
|
||||
const nodeStartPositions = new Map<string, Position>();
|
||||
for (const nodeId of group.nodeIds) {
|
||||
const node = graph.getNode(nodeId);
|
||||
if (node) {
|
||||
nodeStartPositions.set(nodeId, node.position);
|
||||
}
|
||||
}
|
||||
|
||||
setGroupDragState({
|
||||
groupId,
|
||||
startGroupPosition: group.position,
|
||||
startMouse: { x: canvasPos.x, y: canvasPos.y },
|
||||
nodeStartPositions
|
||||
});
|
||||
}, [graph, readOnly, screenToCanvas]);
|
||||
|
||||
/**
|
||||
* Handles group context menu
|
||||
* 处理组右键菜单
|
||||
*/
|
||||
const handleGroupContextMenu = useCallback((group: NodeGroup, e: React.MouseEvent) => {
|
||||
onGroupContextMenu?.(group, e);
|
||||
}, [onGroupContextMenu]);
|
||||
|
||||
/**
|
||||
* Handles group double click
|
||||
* 处理组双击
|
||||
*/
|
||||
const handleGroupDoubleClick = useCallback((group: NodeGroup) => {
|
||||
onGroupDoubleClick?.(group);
|
||||
}, [onGroupDoubleClick]);
|
||||
|
||||
/**
|
||||
* Handles canvas click to deselect
|
||||
* 处理画布点击取消选择
|
||||
*/
|
||||
const handleCanvasClick = useCallback((_position: Position, _e: React.MouseEvent) => {
|
||||
// Skip if box selection just ended (click fires after mouseup)
|
||||
// 如果框选刚刚结束则跳过(click 在 mouseup 之后触发)
|
||||
if (boxSelectJustEndedRef.current) {
|
||||
boxSelectJustEndedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!readOnly) {
|
||||
onSelectionChange?.(new Set(), new Set());
|
||||
setSelectedGroupIds(new Set());
|
||||
}
|
||||
}, [readOnly, onSelectionChange]);
|
||||
|
||||
@@ -441,6 +677,79 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
onCanvasContextMenu?.(position, e);
|
||||
}, [onCanvasContextMenu]);
|
||||
|
||||
/**
|
||||
* Handles box selection start
|
||||
* 处理框选开始
|
||||
*/
|
||||
const handleBoxSelectStart = useCallback((position: Position, e: React.MouseEvent) => {
|
||||
if (readOnly) return;
|
||||
setBoxSelectState({
|
||||
startPos: position,
|
||||
currentPos: position,
|
||||
additive: e.ctrlKey || e.metaKey
|
||||
});
|
||||
}, [readOnly]);
|
||||
|
||||
/**
|
||||
* Handles box selection move
|
||||
* 处理框选移动
|
||||
*/
|
||||
const handleBoxSelectMove = useCallback((position: Position) => {
|
||||
if (boxSelectState) {
|
||||
setBoxSelectState(prev => prev ? { ...prev, currentPos: position } : null);
|
||||
}
|
||||
}, [boxSelectState]);
|
||||
|
||||
/**
|
||||
* Handles box selection end
|
||||
* 处理框选结束
|
||||
*/
|
||||
const handleBoxSelectEnd = useCallback(() => {
|
||||
if (!boxSelectState) return;
|
||||
|
||||
const { startPos, currentPos, additive } = boxSelectState;
|
||||
|
||||
// Calculate selection box bounds
|
||||
const minX = Math.min(startPos.x, currentPos.x);
|
||||
const maxX = Math.max(startPos.x, currentPos.x);
|
||||
const minY = Math.min(startPos.y, currentPos.y);
|
||||
const maxY = Math.max(startPos.y, currentPos.y);
|
||||
|
||||
// Find nodes within the selection box
|
||||
const nodesInBox: string[] = [];
|
||||
const nodeWidth = 200; // Approximate node width
|
||||
const nodeHeight = 100; // Approximate node height
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
const nodeLeft = node.position.x;
|
||||
const nodeTop = node.position.y;
|
||||
const nodeRight = nodeLeft + nodeWidth;
|
||||
const nodeBottom = nodeTop + nodeHeight;
|
||||
|
||||
// Check if node intersects with selection box
|
||||
const intersects = !(nodeRight < minX || nodeLeft > maxX || nodeBottom < minY || nodeTop > maxY);
|
||||
if (intersects) {
|
||||
nodesInBox.push(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Update selection
|
||||
if (additive) {
|
||||
// Add to existing selection
|
||||
const newSelection = new Set(selectedNodeIds);
|
||||
nodesInBox.forEach(id => newSelection.add(id));
|
||||
onSelectionChange?.(newSelection, new Set());
|
||||
} else {
|
||||
// Replace selection
|
||||
onSelectionChange?.(new Set(nodesInBox), new Set());
|
||||
}
|
||||
|
||||
// Mark that box selection just ended to prevent click from clearing selection
|
||||
// 标记框选刚刚结束,以防止 click 清除选择
|
||||
boxSelectJustEndedRef.current = true;
|
||||
setBoxSelectState(null);
|
||||
}, [boxSelectState, graph.nodes, selectedNodeIds, onSelectionChange]);
|
||||
|
||||
/**
|
||||
* Handles pin value change
|
||||
* 处理引脚值变化
|
||||
@@ -497,7 +806,25 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
onContextMenu={handleCanvasContextMenu}
|
||||
onPanChange={handlePanChange}
|
||||
onZoomChange={handleZoomChange}
|
||||
onMouseDown={handleBoxSelectStart}
|
||||
onCanvasMouseMove={handleBoxSelectMove}
|
||||
onCanvasMouseUp={handleBoxSelectEnd}
|
||||
>
|
||||
{/* Group boxes - rendered first so they appear behind nodes (组框 - 先渲染,这样显示在节点后面) */}
|
||||
{/* Use dynamically calculated bounds so groups auto-resize to fit nodes */}
|
||||
{groupsWithDynamicBounds.map(group => (
|
||||
<MemoizedGroupNodeComponent
|
||||
key={group.id}
|
||||
group={group}
|
||||
isSelected={selectedGroupIds.has(group.id)}
|
||||
isDragging={groupDragState?.groupId === group.id}
|
||||
onSelect={handleGroupSelect}
|
||||
onDragStart={handleGroupDragStart}
|
||||
onContextMenu={handleGroupContextMenu}
|
||||
onDoubleClick={handleGroupDoubleClick}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Connection layer (连接层) */}
|
||||
<ConnectionLayer
|
||||
connections={graph.connections}
|
||||
@@ -509,7 +836,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
onConnectionContextMenu={handleConnectionContextMenu}
|
||||
/>
|
||||
|
||||
{/* Nodes (节点) */}
|
||||
{/* All Nodes (所有节点) */}
|
||||
{graph.nodes.map(node => (
|
||||
<MemoizedGraphNodeComponent
|
||||
key={node.id}
|
||||
@@ -531,6 +858,19 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
|
||||
onToggleCollapse={handleToggleCollapse}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Box selection overlay (框选覆盖层) */}
|
||||
{boxSelectState && (
|
||||
<div
|
||||
className="ne-selection-box"
|
||||
style={{
|
||||
left: Math.min(boxSelectState.startPos.x, boxSelectState.currentPos.x),
|
||||
top: Math.min(boxSelectState.startPos.y, boxSelectState.currentPos.y),
|
||||
width: Math.abs(boxSelectState.currentPos.x - boxSelectState.startPos.x),
|
||||
height: Math.abs(boxSelectState.currentPos.y - boxSelectState.startPos.y)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</GraphCanvas>
|
||||
</div>
|
||||
);
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
@@ -4,3 +4,9 @@ export {
|
||||
type GraphNodeComponentProps,
|
||||
type NodeExecutionState
|
||||
} from './GraphNodeComponent';
|
||||
|
||||
export {
|
||||
GroupNodeComponent,
|
||||
MemoizedGroupNodeComponent,
|
||||
type GroupNodeComponentProps
|
||||
} from './GroupNodeComponent';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user