Compare commits
60 Commits
v2.4.1
...
@esengine/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fac4bc19c5 | ||
|
|
aed91dbe45 | ||
|
|
c7f8208b6f | ||
|
|
5131ec3c52 | ||
|
|
7d74623710 | ||
|
|
044463dd5f | ||
|
|
ce2db4e48a | ||
|
|
0a88c6f2fc | ||
|
|
b0b95c60b4 | ||
|
|
683ac7a7d4 | ||
|
|
1e240e86f2 | ||
|
|
4d6c2fe7ff | ||
|
|
67c06720c5 | ||
|
|
33e98b9a75 | ||
|
|
a42f2412d7 | ||
|
|
fdb19a33fb | ||
|
|
1e31e9101b | ||
|
|
d66c18041e | ||
|
|
881ffad3bc | ||
|
|
4a16e30794 | ||
|
|
76691cc198 | ||
|
|
27b9e174eb | ||
|
|
ede440d277 | ||
|
|
5cb83f0743 | ||
|
|
7cbf92b8c7 | ||
|
|
a049bbe2f5 | ||
|
|
ec72df7af5 | ||
|
|
9327c1cef5 | ||
|
|
da5bf2116a | ||
|
|
67e97f89c6 | ||
|
|
31fd34b221 | ||
|
|
c4f7a13b74 | ||
|
|
155411e743 | ||
|
|
a84ff902e4 | ||
|
|
54038e3250 | ||
|
|
5544fca002 | ||
|
|
88b5ffc0a7 | ||
|
|
0c0a5f10f7 | ||
|
|
56e322de7f | ||
|
|
c2ebd387f2 | ||
|
|
e5e647f1a4 | ||
|
|
4d501ba448 | ||
|
|
275124b66c | ||
|
|
25936c19e9 | ||
|
|
f43631a1e1 | ||
|
|
f8c181836e | ||
|
|
840eb3452e | ||
|
|
0bf849e193 | ||
|
|
ebb984d354 | ||
|
|
068ca4bf69 | ||
|
|
4089051731 | ||
|
|
6b8b65ae16 | ||
|
|
a75c61c049 | ||
|
|
770c05402d | ||
|
|
9d581ccd8d | ||
|
|
235c432edb | ||
|
|
dbc6793dc4 | ||
|
|
58f70a5783 | ||
|
|
828ff969e1 | ||
|
|
49dd6a91c6 |
8
.changeset/README.md
Normal file
8
.changeset/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||
57
.changeset/config.json
Normal file
57
.changeset/config.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
|
||||
"changelog": [
|
||||
"@changesets/changelog-github",
|
||||
{ "repo": "esengine/esengine" }
|
||||
],
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [
|
||||
["@esengine/ecs-framework", "@esengine/ecs-framework-math"]
|
||||
],
|
||||
"access": "public",
|
||||
"baseBranch": "master",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": [
|
||||
"@esengine/engine-core",
|
||||
"@esengine/runtime-core",
|
||||
"@esengine/asset-system",
|
||||
"@esengine/material-system",
|
||||
"@esengine/ecs-engine-bindgen",
|
||||
"@esengine/script-runtime",
|
||||
"@esengine/platform-common",
|
||||
"@esengine/platform-web",
|
||||
"@esengine/platform-wechat",
|
||||
"@esengine/sprite",
|
||||
"@esengine/camera",
|
||||
"@esengine/particle",
|
||||
"@esengine/tilemap",
|
||||
"@esengine/mesh-3d",
|
||||
"@esengine/effect",
|
||||
"@esengine/audio",
|
||||
"@esengine/fairygui",
|
||||
"@esengine/physics-rapier2d",
|
||||
"@esengine/rapier2d",
|
||||
"@esengine/world-streaming",
|
||||
"@esengine/editor-core",
|
||||
"@esengine/editor-runtime",
|
||||
"@esengine/editor-app",
|
||||
"@esengine/sprite-editor",
|
||||
"@esengine/camera-editor",
|
||||
"@esengine/particle-editor",
|
||||
"@esengine/tilemap-editor",
|
||||
"@esengine/mesh-3d-editor",
|
||||
"@esengine/fairygui-editor",
|
||||
"@esengine/physics-rapier2d-editor",
|
||||
"@esengine/behavior-tree-editor",
|
||||
"@esengine/blueprint-editor",
|
||||
"@esengine/asset-system-editor",
|
||||
"@esengine/material-editor",
|
||||
"@esengine/shader-editor",
|
||||
"@esengine/world-streaming-editor",
|
||||
"@esengine/node-editor",
|
||||
"@esengine/sdk",
|
||||
"@esengine/worker-generator",
|
||||
"@esengine/engine"
|
||||
]
|
||||
}
|
||||
5
.github/codeql/codeql-config.yml
vendored
5
.github/codeql/codeql-config.yml
vendored
@@ -6,3 +6,8 @@ paths-ignore:
|
||||
- "**/node_modules"
|
||||
- "**/dist"
|
||||
- "**/bin"
|
||||
- "**/tests"
|
||||
- "**/*.test.ts"
|
||||
- "**/*.spec.ts"
|
||||
- "**/test"
|
||||
- "**/__tests__"
|
||||
|
||||
32
.github/labeler.yml
vendored
32
.github/labeler.yml
vendored
@@ -1,32 +0,0 @@
|
||||
# 自动标签配置
|
||||
# 根据 issue/PR 内容自动打标签
|
||||
|
||||
'bug':
|
||||
- '/(bug|错误|崩溃|crash|error|exception|问题)/i'
|
||||
|
||||
'enhancement':
|
||||
- '/(feature|功能|enhancement|improve|优化|建议)/i'
|
||||
|
||||
'documentation':
|
||||
- '/(doc|文档|readme|guide|tutorial|教程)/i'
|
||||
|
||||
'question':
|
||||
- '/(question|疑问|how to|如何|怎么)/i'
|
||||
|
||||
'performance':
|
||||
- '/(performance|性能|slow|慢|lag|卡顿|optimize)/i'
|
||||
|
||||
'core':
|
||||
- '/(@esengine\/ecs-framework|packages\/core|core package)/i'
|
||||
|
||||
'editor':
|
||||
- '/(editor|编辑器|tauri)/i'
|
||||
|
||||
'network':
|
||||
- '/(network|网络|multiplayer|多人)/i'
|
||||
|
||||
'help wanted':
|
||||
- '/(help wanted|需要帮助|求助)/i'
|
||||
|
||||
'good first issue':
|
||||
- '/(good first issue|新手友好|beginner)/i'
|
||||
73
.github/workflows/ai-batch-analyze-issues.yml
vendored
73
.github/workflows/ai-batch-analyze-issues.yml
vendored
@@ -1,73 +0,0 @@
|
||||
name: AI Batch Analyze Issues
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: '分析模式'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- 'recent' # 最近 10 个 issue
|
||||
- 'open' # 所有打开的 issue
|
||||
- 'all' # 所有 issue(慎用)
|
||||
default: 'recent'
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: Install GitHub CLI
|
||||
run: |
|
||||
gh --version || (curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install gh)
|
||||
|
||||
- name: Batch Analyze Issues
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
MODE="${{ github.event.inputs.mode }}"
|
||||
|
||||
# 获取 issue 列表
|
||||
if [ "$MODE" = "recent" ]; then
|
||||
echo "📊 分析最近 10 个 issue..."
|
||||
ISSUES=$(gh issue list --limit 10 --json number --jq '.[].number')
|
||||
elif [ "$MODE" = "open" ]; then
|
||||
echo "📊 分析所有打开的 issue..."
|
||||
ISSUES=$(gh issue list --state open --json number --jq '.[].number')
|
||||
else
|
||||
echo "📊 分析所有 issue(这可能需要很长时间)..."
|
||||
ISSUES=$(gh issue list --state all --limit 100 --json number --jq '.[].number')
|
||||
fi
|
||||
|
||||
# 为每个 issue 添加 AI 分析评论
|
||||
for issue_num in $ISSUES; do
|
||||
echo "🤖 分析 Issue #$issue_num..."
|
||||
|
||||
# 获取 issue 内容
|
||||
ISSUE_BODY=$(gh issue view $issue_num --json body --jq '.body')
|
||||
ISSUE_TITLE=$(gh issue view $issue_num --json title --jq '.title')
|
||||
|
||||
# 添加触发评论
|
||||
gh issue comment $issue_num --body "@ai-helper 请帮我分析这个 issue" || true
|
||||
|
||||
# 避免 API 限制
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "✅ 批量分析完成!"
|
||||
echo "查看结果:https://github.com/${{ github.repository }}/issues"
|
||||
61
.github/workflows/ai-helper-tip.yml
vendored
61
.github/workflows/ai-helper-tip.yml
vendored
@@ -1,61 +0,0 @@
|
||||
name: AI Helper Tip
|
||||
|
||||
# 对所有新创建的 issue 自动回复 AI 助手使用说明(新老用户都适用)
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
tip:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Post AI Helper Usage Tip
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const message = [
|
||||
"## 🤖 AI 助手可用 | AI Helper Available",
|
||||
"",
|
||||
"**中文说明:**",
|
||||
"",
|
||||
"本项目配备了 AI 智能助手,可以帮助你快速获得解答!",
|
||||
"",
|
||||
"**使用方法:** 在评论中提及 `@ai-helper`,AI 会自动搜索项目代码并提供解决方案。",
|
||||
"",
|
||||
"**示例:**",
|
||||
"```",
|
||||
"@ai-helper 如何创建一个新的 System?",
|
||||
"@ai-helper 这个报错是什么原因?",
|
||||
"```",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"**English:**",
|
||||
"",
|
||||
"This project has an AI assistant to help you get answers quickly!",
|
||||
"",
|
||||
"**How to use:** Mention `@ai-helper` in a comment, and AI will automatically search the codebase and provide solutions.",
|
||||
"",
|
||||
"**Examples:**",
|
||||
"```",
|
||||
"@ai-helper How do I create a new System?",
|
||||
"@ai-helper What causes this error?",
|
||||
"```",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"💡 *AI 助手基于代码库提供建议,复杂问题建议等待维护者回复*",
|
||||
"💡 *AI suggestions are based on the codebase. For complex issues, please wait for maintainer responses*"
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: message
|
||||
});
|
||||
|
||||
console.log('✅ AI helper tip posted successfully');
|
||||
85
.github/workflows/ai-issue-helper.yml
vendored
85
.github/workflows/ai-issue-helper.yml
vendored
@@ -1,85 +0,0 @@
|
||||
name: AI Issue Helper
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
models: read
|
||||
|
||||
jobs:
|
||||
ai-helper:
|
||||
runs-on: ubuntu-latest
|
||||
# 只在真实用户提到 @ai-helper 时触发,忽略机器人评论
|
||||
if: |
|
||||
contains(github.event.comment.body, '@ai-helper') &&
|
||||
github.event.comment.user.type != 'Bot'
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get Issue Details
|
||||
id: issue
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number
|
||||
});
|
||||
|
||||
// 限制长度,避免超过 token 限制
|
||||
const maxLength = 1000;
|
||||
const truncate = (str, max) => {
|
||||
if (!str) return '';
|
||||
return str.length > max ? str.substring(0, max) + '...[内容过长已截断]' : str;
|
||||
};
|
||||
|
||||
core.exportVariable('ISSUE_TITLE', truncate(issue.data.title || '', 200));
|
||||
core.exportVariable('ISSUE_BODY', truncate(issue.data.body || '', maxLength));
|
||||
core.exportVariable('COMMENT_BODY', truncate(context.payload.comment.body || '', 500));
|
||||
core.exportVariable('ISSUE_NUMBER', context.issue.number);
|
||||
|
||||
- name: Create Prompt
|
||||
id: prompt
|
||||
run: |
|
||||
cat > prompt.txt << 'PROMPT_EOF'
|
||||
Issue #${{ env.ISSUE_NUMBER }}
|
||||
|
||||
标题: ${{ env.ISSUE_TITLE }}
|
||||
|
||||
内容: ${{ env.ISSUE_BODY }}
|
||||
|
||||
评论: ${{ env.COMMENT_BODY }}
|
||||
|
||||
请搜索项目代码并提供解决方案。
|
||||
PROMPT_EOF
|
||||
|
||||
- name: AI Analysis
|
||||
uses: actions/ai-inference@v1
|
||||
id: ai
|
||||
with:
|
||||
model: 'gpt-4o'
|
||||
enable-github-mcp: true
|
||||
max-tokens: 1500
|
||||
system-prompt: |
|
||||
你是 ECS Framework (TypeScript ECS 框架) 的 AI 助手。
|
||||
主要代码在 packages/core/src。
|
||||
搜索相关代码后,用中文简洁回答问题,包含问题分析、解决方案和代码引用。
|
||||
prompt-file: prompt.txt
|
||||
|
||||
- name: Post AI Response
|
||||
env:
|
||||
AI_RESPONSE: ${{ steps.ai.outputs.response }}
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: process.env.AI_RESPONSE
|
||||
});
|
||||
56
.github/workflows/ai-issue-moderator.yml
vendored
56
.github/workflows/ai-issue-moderator.yml
vendored
@@ -1,56 +0,0 @@
|
||||
name: AI Issue Moderator
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
models: read
|
||||
|
||||
jobs:
|
||||
moderate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check Content
|
||||
uses: actions/ai-inference@v1
|
||||
id: check
|
||||
with:
|
||||
model: 'gpt-4o-mini'
|
||||
system-prompt: |
|
||||
你是一个内容审查助手。
|
||||
检查内容是否包含:
|
||||
1. 垃圾信息或广告
|
||||
2. 恶意或攻击性内容
|
||||
3. 与项目完全无关的内容
|
||||
|
||||
只返回 "SPAM" 或 "OK",不要其他内容。
|
||||
prompt: |
|
||||
标题:${{ github.event.issue.title || github.event.comment.body }}
|
||||
|
||||
内容:
|
||||
${{ github.event.issue.body || github.event.comment.body }}
|
||||
|
||||
- name: Mark as Spam
|
||||
if: contains(steps.check.outputs.response, 'SPAM')
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
// 添加 spam 标签
|
||||
github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['spam']
|
||||
});
|
||||
|
||||
// 添加评论
|
||||
github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: '🤖 这个内容被 AI 检测为可能的垃圾内容。如果这是误判,请联系维护者。\n\n🤖 This content was detected as potential spam by AI. If this is a false positive, please contact the maintainers.'
|
||||
});
|
||||
160
.github/workflows/batch-label-issues.yml
vendored
160
.github/workflows/batch-label-issues.yml
vendored
@@ -1,160 +0,0 @@
|
||||
name: Batch Label Issues
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: '标签模式'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- 'recent' # 最近 20 个 issue
|
||||
- 'open' # 所有打开的 issue
|
||||
- 'unlabeled' # 只处理没有标签的 issue
|
||||
- 'all' # 所有 issue(慎用)
|
||||
default: 'recent'
|
||||
|
||||
skip_labeled:
|
||||
description: '跳过已有标签的 issue'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
batch-label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: Batch Label Issues
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
MODE="${{ github.event.inputs.mode }}"
|
||||
SKIP_LABELED="${{ github.event.inputs.skip_labeled }}"
|
||||
|
||||
echo "📊 开始批量打标签..."
|
||||
echo "模式: $MODE"
|
||||
echo "跳过已标签: $SKIP_LABELED"
|
||||
|
||||
# 获取 issue 列表
|
||||
if [ "$MODE" = "recent" ]; then
|
||||
echo "📋 获取最近 20 个 issue..."
|
||||
ISSUES=$(gh issue list --limit 20 --json number,labels,title,body --jq '.[] | {number, labels: [.labels[].name], title, body}')
|
||||
elif [ "$MODE" = "open" ]; then
|
||||
echo "📋 获取所有打开的 issue..."
|
||||
ISSUES=$(gh issue list --state open --json number,labels,title,body --jq '.[] | {number, labels: [.labels[].name], title, body}')
|
||||
elif [ "$MODE" = "unlabeled" ]; then
|
||||
echo "📋 获取没有标签的 issue..."
|
||||
ISSUES=$(gh issue list --state all --json number,labels,title,body --jq '.[] | select(.labels | length == 0) | {number, labels: [.labels[].name], title, body}')
|
||||
else
|
||||
echo "📋 获取所有 issue(限制 100 个)..."
|
||||
ISSUES=$(gh issue list --state all --limit 100 --json number,labels,title,body --jq '.[] | {number, labels: [.labels[].name], title, body}')
|
||||
fi
|
||||
|
||||
# 临时文件
|
||||
echo "$ISSUES" > /tmp/issues.json
|
||||
|
||||
# 处理每个 issue
|
||||
cat /tmp/issues.json | jq -c '.' | while read -r issue; do
|
||||
ISSUE_NUM=$(echo "$issue" | jq -r '.number')
|
||||
EXISTING_LABELS=$(echo "$issue" | jq -r '.labels | join(",")')
|
||||
TITLE=$(echo "$issue" | jq -r '.title')
|
||||
BODY=$(echo "$issue" | jq -r '.body')
|
||||
|
||||
echo ""
|
||||
echo "🔍 处理 Issue #$ISSUE_NUM: $TITLE"
|
||||
echo " 现有标签: $EXISTING_LABELS"
|
||||
|
||||
# 跳过已有标签的 issue
|
||||
if [ "$SKIP_LABELED" = "true" ] && [ ! -z "$EXISTING_LABELS" ]; then
|
||||
echo " ⏭️ 跳过(已有标签)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# 分析内容并打标签
|
||||
LABELS_TO_ADD=""
|
||||
|
||||
# 检测 bug
|
||||
if echo "$TITLE $BODY" | grep -iE "(bug|错误|崩溃|crash|error|exception|问题|fix)" > /dev/null; then
|
||||
LABELS_TO_ADD="$LABELS_TO_ADD bug"
|
||||
echo " 🐛 检测到: bug"
|
||||
fi
|
||||
|
||||
# 检测 feature request
|
||||
if echo "$TITLE $BODY" | grep -iE "(feature|功能|enhancement|improve|优化|建议|新增|添加|add)" > /dev/null; then
|
||||
LABELS_TO_ADD="$LABELS_TO_ADD enhancement"
|
||||
echo " ✨ 检测到: enhancement"
|
||||
fi
|
||||
|
||||
# 检测 question
|
||||
if echo "$TITLE $BODY" | grep -iE "(question|疑问|how to|如何|怎么|为什么|why|咨询|\?|?)" > /dev/null; then
|
||||
LABELS_TO_ADD="$LABELS_TO_ADD question"
|
||||
echo " ❓ 检测到: question"
|
||||
fi
|
||||
|
||||
# 检测 documentation
|
||||
if echo "$TITLE $BODY" | grep -iE "(doc|文档|readme|guide|tutorial|教程|说明)" > /dev/null; then
|
||||
LABELS_TO_ADD="$LABELS_TO_ADD documentation"
|
||||
echo " 📖 检测到: documentation"
|
||||
fi
|
||||
|
||||
# 检测 performance
|
||||
if echo "$TITLE $BODY" | grep -iE "(performance|性能|slow|慢|lag|卡顿|optimize|优化)" > /dev/null; then
|
||||
LABELS_TO_ADD="$LABELS_TO_ADD performance"
|
||||
echo " ⚡ 检测到: performance"
|
||||
fi
|
||||
|
||||
# 检测 core
|
||||
if echo "$TITLE $BODY" | grep -iE "(@esengine/ecs-framework|packages/core|core package|核心包)" > /dev/null; then
|
||||
LABELS_TO_ADD="$LABELS_TO_ADD core"
|
||||
echo " 🎯 检测到: core"
|
||||
fi
|
||||
|
||||
# 检测 editor
|
||||
if echo "$TITLE $BODY" | grep -iE "(editor|编辑器|tauri)" > /dev/null; then
|
||||
LABELS_TO_ADD="$LABELS_TO_ADD editor"
|
||||
echo " 🎨 检测到: editor"
|
||||
fi
|
||||
|
||||
# 检测 network
|
||||
if echo "$TITLE $BODY" | grep -iE "(network|网络|multiplayer|多人|同步)" > /dev/null; then
|
||||
LABELS_TO_ADD="$LABELS_TO_ADD network"
|
||||
echo " 🌐 检测到: network"
|
||||
fi
|
||||
|
||||
# 检测 help wanted
|
||||
if echo "$TITLE $BODY" | grep -iE "(help wanted|需要帮助|求助)" > /dev/null; then
|
||||
LABELS_TO_ADD="$LABELS_TO_ADD help wanted"
|
||||
echo " 🆘 检测到: help wanted"
|
||||
fi
|
||||
|
||||
# 添加标签
|
||||
if [ ! -z "$LABELS_TO_ADD" ]; then
|
||||
echo " ✅ 添加标签: $LABELS_TO_ADD"
|
||||
for label in $LABELS_TO_ADD; do
|
||||
gh issue edit $ISSUE_NUM --add-label "$label" 2>&1 | grep -v "already exists" || true
|
||||
done
|
||||
echo " 💬 添加说明评论..."
|
||||
gh issue comment $ISSUE_NUM --body $'🤖 自动标签系统检测到此 issue 并添加了相关标签。如有误判,请告知维护者。\n\n🤖 Auto-labeling system detected and labeled this issue. Please let maintainers know if this is incorrect.' || true
|
||||
else
|
||||
echo " ℹ️ 未检测到明确类型"
|
||||
fi
|
||||
|
||||
# 避免 API 限制
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "✅ 批量标签完成!"
|
||||
echo "查看结果: https://github.com/${{ github.repository }}/issues"
|
||||
119
.github/workflows/ci.yml
vendored
119
.github/workflows/ci.yml
vendored
@@ -13,18 +13,35 @@ on:
|
||||
- '.github/workflows/ci.yml'
|
||||
pull_request:
|
||||
branches: [ master, main, develop ]
|
||||
paths:
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'tsconfig.json'
|
||||
- 'turbo.json'
|
||||
- 'jest.config.*'
|
||||
- '.github/workflows/ci.yml'
|
||||
# Run on all PRs to satisfy branch protection, but skip build if no code changes
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
# Check if we need to run the full CI
|
||||
check-changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should-run: ${{ steps.filter.outputs.code }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
code:
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'tsconfig.json'
|
||||
- 'turbo.json'
|
||||
- 'jest.config.*'
|
||||
|
||||
ci:
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.should-run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -39,67 +56,35 @@ jobs:
|
||||
node-version: '20.x'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
# 缓存 Rust 编译结果
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: packages/engine
|
||||
cache-on-failure: true
|
||||
|
||||
# 缓存 wasm-pack
|
||||
- name: Cache wasm-pack
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cargo/bin/wasm-pack
|
||||
key: wasm-pack-${{ runner.os }}
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: |
|
||||
if ! command -v wasm-pack &> /dev/null; then
|
||||
cargo install wasm-pack
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
# 缓存 Turbo
|
||||
- name: Cache Turbo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .turbo
|
||||
key: turbo-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
turbo-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||
turbo-${{ runner.os }}-
|
||||
|
||||
# 构建所有包
|
||||
- name: Build all packages
|
||||
run: pnpm run build
|
||||
|
||||
- name: Copy WASM files to ecs-engine-bindgen
|
||||
# 构建 framework 包 (可独立发布的通用库,无外部依赖)
|
||||
- name: Build framework packages
|
||||
run: |
|
||||
mkdir -p packages/ecs-engine-bindgen/src/wasm
|
||||
cp packages/engine/pkg/es_engine.js packages/ecs-engine-bindgen/src/wasm/
|
||||
cp packages/engine/pkg/es_engine.d.ts packages/ecs-engine-bindgen/src/wasm/
|
||||
cp packages/engine/pkg/es_engine_bg.wasm packages/ecs-engine-bindgen/src/wasm/
|
||||
cp packages/engine/pkg/es_engine_bg.wasm.d.ts packages/ecs-engine-bindgen/src/wasm/
|
||||
pnpm --filter @esengine/ecs-framework build
|
||||
pnpm --filter @esengine/ecs-framework-math build
|
||||
pnpm --filter @esengine/behavior-tree build
|
||||
pnpm --filter @esengine/blueprint build
|
||||
pnpm --filter @esengine/fsm build
|
||||
pnpm --filter @esengine/timer build
|
||||
pnpm --filter @esengine/spatial build
|
||||
pnpm --filter @esengine/procgen build
|
||||
pnpm --filter @esengine/pathfinding build
|
||||
pnpm --filter @esengine/network-protocols build
|
||||
pnpm --filter @esengine/network build
|
||||
|
||||
# 类型检查
|
||||
- name: Type check
|
||||
run: pnpm run type-check
|
||||
# 类型检查 (仅 framework 包)
|
||||
- name: Type check (framework packages)
|
||||
run: pnpm run type-check:framework
|
||||
|
||||
# Lint 检查
|
||||
- name: Lint check
|
||||
run: pnpm run lint
|
||||
# Lint 检查 (仅 framework 包)
|
||||
- name: Lint check (framework packages)
|
||||
run: pnpm run lint:framework
|
||||
|
||||
# 测试
|
||||
# 测试 (仅 framework 包)
|
||||
- name: Run tests with coverage
|
||||
run: pnpm run test:ci
|
||||
run: pnpm run test:ci:framework
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
@@ -110,9 +95,11 @@ jobs:
|
||||
name: codecov-umbrella
|
||||
fail_ci_if_error: false
|
||||
|
||||
# 构建 npm 包
|
||||
# 构建 npm 包 (core 和 math)
|
||||
- name: Build npm packages
|
||||
run: pnpm run build:npm
|
||||
run: |
|
||||
pnpm run build:npm:core
|
||||
pnpm run build:npm:math
|
||||
|
||||
# 上传构建产物
|
||||
- name: Upload build artifacts
|
||||
@@ -120,6 +107,6 @@ jobs:
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: |
|
||||
packages/*/dist/
|
||||
packages/*/bin/
|
||||
packages/framework/**/dist/
|
||||
packages/framework/**/bin/
|
||||
retention-days: 7
|
||||
|
||||
146
.github/workflows/cleanup-dependabot.yml
vendored
146
.github/workflows/cleanup-dependabot.yml
vendored
@@ -1,146 +0,0 @@
|
||||
name: Cleanup Old Dependabot PRs
|
||||
|
||||
# 手动触发的 workflow,用于清理堆积的 Dependabot PR
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
days_old:
|
||||
description: '关闭多少天前创建的 PR(默认 7 天)'
|
||||
required: false
|
||||
default: '7'
|
||||
dry_run:
|
||||
description: '试运行模式(true=仅显示,不关闭)'
|
||||
required: false
|
||||
default: 'true'
|
||||
type: choice
|
||||
options:
|
||||
- 'true'
|
||||
- 'false'
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: List and Close Old Dependabot PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const daysOld = parseInt('${{ github.event.inputs.days_old }}') || 7;
|
||||
const dryRun = '${{ github.event.inputs.dry_run }}' === 'true';
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
|
||||
|
||||
console.log(`🔍 查找超过 ${daysOld} 天的 Dependabot PR...`);
|
||||
console.log(`📅 截止日期: ${cutoffDate.toISOString()}`);
|
||||
console.log(`🏃 模式: ${dryRun ? '试运行(不会实际关闭)' : '实际执行'}`);
|
||||
console.log('---');
|
||||
|
||||
// 获取所有 Dependabot PR
|
||||
const { data: pulls } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
const dependabotPRs = pulls.filter(pr =>
|
||||
pr.user.login === 'dependabot[bot]' &&
|
||||
new Date(pr.created_at) < cutoffDate
|
||||
);
|
||||
|
||||
console.log(`📊 找到 ${dependabotPRs.length} 个符合条件的 Dependabot PR`);
|
||||
console.log('');
|
||||
|
||||
if (dependabotPRs.length === 0) {
|
||||
console.log('✅ 没有需要清理的 PR');
|
||||
return;
|
||||
}
|
||||
|
||||
// 按类型分组
|
||||
const byType = {
|
||||
dev: [],
|
||||
prod: [],
|
||||
actions: [],
|
||||
other: []
|
||||
};
|
||||
|
||||
for (const pr of dependabotPRs) {
|
||||
const title = pr.title.toLowerCase();
|
||||
const labels = pr.labels.map(l => l.name);
|
||||
|
||||
let type = 'other';
|
||||
if (title.includes('dev-dependencies') || title.includes('development')) {
|
||||
type = 'dev';
|
||||
} else if (title.includes('production-dependencies')) {
|
||||
type = 'prod';
|
||||
} else if (labels.includes('github-actions')) {
|
||||
type = 'actions';
|
||||
}
|
||||
|
||||
byType[type].push(pr);
|
||||
}
|
||||
|
||||
console.log('📋 PR 分类统计:');
|
||||
console.log(` 🔧 开发依赖: ${byType.dev.length} 个`);
|
||||
console.log(` 📦 生产依赖: ${byType.prod.length} 个`);
|
||||
console.log(` ⚙️ GitHub Actions: ${byType.actions.length} 个`);
|
||||
console.log(` ❓ 其他: ${byType.other.length} 个`);
|
||||
console.log('');
|
||||
|
||||
// 处理每个 PR
|
||||
for (const pr of dependabotPRs) {
|
||||
const age = Math.floor((Date.now() - new Date(pr.created_at)) / (1000 * 60 * 60 * 24));
|
||||
|
||||
console.log(`${dryRun ? '🔍' : '🗑️ '} #${pr.number}: ${pr.title}`);
|
||||
console.log(` 创建时间: ${pr.created_at} (${age} 天前)`);
|
||||
console.log(` 链接: ${pr.html_url}`);
|
||||
|
||||
if (!dryRun) {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed'
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: `🤖 **自动关闭旧的 Dependabot PR**
|
||||
|
||||
此 PR 已超过 ${daysOld} 天未合并,已被自动关闭以清理积压。
|
||||
|
||||
📌 **下一步:**
|
||||
- Dependabot 已配置为月度运行,届时会创建新的分组更新
|
||||
- 新的 Mergify 规则会智能处理不同类型的依赖更新
|
||||
- 开发依赖和 GitHub Actions 会自动合并(即使 CI 失败)
|
||||
- 生产依赖需要 CI 通过才会自动合并
|
||||
|
||||
如果需要立即应用此更新,请手动更新依赖。
|
||||
|
||||
---
|
||||
*此操作由仓库维护者手动触发的清理工作流执行*`
|
||||
});
|
||||
|
||||
console.log(' ✅ 已关闭并添加说明');
|
||||
} else {
|
||||
console.log(' ℹ️ 试运行模式 - 未执行操作');
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log('---');
|
||||
if (dryRun) {
|
||||
console.log(`✨ 试运行完成!共发现 ${dependabotPRs.length} 个待清理的 PR`);
|
||||
console.log('💡 要实际执行清理,请将 dry_run 参数设为 false 重新运行');
|
||||
} else {
|
||||
console.log(`✅ 清理完成!已关闭 ${dependabotPRs.length} 个 Dependabot PR`);
|
||||
}
|
||||
6
.github/workflows/codecov.yml
vendored
6
.github/workflows/codecov.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
cd packages/core
|
||||
cd packages/framework/core
|
||||
pnpm run test:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/core/coverage/coverage-final.json
|
||||
files: ./packages/framework/core/coverage/coverage-final.json
|
||||
flags: core
|
||||
name: core-coverage
|
||||
fail_ci_if_error: false
|
||||
@@ -46,4 +46,4 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: packages/core/coverage/
|
||||
path: packages/framework/core/coverage/
|
||||
|
||||
23
.github/workflows/issue-labeler.yml
vendored
23
.github/workflows/issue-labeler.yml
vendored
@@ -1,23 +0,0 @@
|
||||
name: Issue Labeler
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Label Issues
|
||||
uses: github/issue-labeler@v3.4
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
configuration-path: .github/labeler.yml
|
||||
enable-versioned-regex: 1
|
||||
28
.github/workflows/issue-translator.yml
vendored
28
.github/workflows/issue-translator.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Issue Translator
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Translate Issues
|
||||
uses: tomsun28/issues-translate-action@v2.7
|
||||
with:
|
||||
IS_MODIFY_TITLE: false
|
||||
# 设置为 true 会修改标题,false 只在评论中添加翻译
|
||||
CUSTOM_BOT_NOTE: |
|
||||
<details>
|
||||
<summary>🌏 Translation / 翻译</summary>
|
||||
|
||||
Bot detected the issue body's language is not English, translate it automatically.
|
||||
机器人检测到 issue 内容非英文,自动翻译。
|
||||
|
||||
</details>
|
||||
70
.github/workflows/release-changesets.yml
vendored
Normal file
70
.github/workflows/release-changesets.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Release (Changesets)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- '.changeset/**'
|
||||
- 'packages/*/package.json'
|
||||
- 'packages/*/*/package.json'
|
||||
- 'packages/*/*/*/package.json'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build framework packages
|
||||
run: |
|
||||
# Only build packages managed by Changesets (not in ignore list)
|
||||
pnpm --filter "@esengine/ecs-framework" build
|
||||
pnpm --filter "@esengine/ecs-framework-math" build
|
||||
pnpm --filter "@esengine/behavior-tree" build
|
||||
pnpm --filter "@esengine/blueprint" build
|
||||
pnpm --filter "@esengine/fsm" build
|
||||
pnpm --filter "@esengine/timer" build
|
||||
pnpm --filter "@esengine/spatial" build
|
||||
pnpm --filter "@esengine/procgen" build
|
||||
pnpm --filter "@esengine/pathfinding" build
|
||||
pnpm --filter "@esengine/network-protocols" build
|
||||
pnpm --filter "@esengine/network" build
|
||||
pnpm --filter "@esengine/cli" build
|
||||
|
||||
- name: Create Release Pull Request or Publish
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm changeset:version
|
||||
publish: pnpm changeset:publish
|
||||
title: 'chore: release packages'
|
||||
commit: 'chore: release packages'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
28
.github/workflows/release-editor.yml
vendored
28
.github/workflows/release-editor.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: packages/editor-app/src-tauri
|
||||
workspaces: packages/editor/editor-app/src-tauri
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Install dependencies (Ubuntu)
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
- name: Update version in config files (for manual trigger)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
cd packages/editor-app
|
||||
cd packages/editor/editor-app
|
||||
node -e "const pkg=require('./package.json'); pkg.version='${{ github.event.inputs.version }}'; require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2)+'\n')"
|
||||
node scripts/sync-version.js
|
||||
|
||||
@@ -80,15 +80,15 @@ jobs:
|
||||
- name: Copy WASM files to ecs-engine-bindgen
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p packages/ecs-engine-bindgen/src/wasm
|
||||
cp packages/engine/pkg/es_engine.js packages/ecs-engine-bindgen/src/wasm/
|
||||
cp packages/engine/pkg/es_engine.d.ts packages/ecs-engine-bindgen/src/wasm/
|
||||
cp packages/engine/pkg/es_engine_bg.wasm packages/ecs-engine-bindgen/src/wasm/
|
||||
cp packages/engine/pkg/es_engine_bg.wasm.d.ts packages/ecs-engine-bindgen/src/wasm/
|
||||
mkdir -p packages/engine/ecs-engine-bindgen/src/wasm
|
||||
cp packages/rust/engine/pkg/es_engine.js packages/engine/ecs-engine-bindgen/src/wasm/
|
||||
cp packages/rust/engine/pkg/es_engine.d.ts packages/engine/ecs-engine-bindgen/src/wasm/
|
||||
cp packages/rust/engine/pkg/es_engine_bg.wasm packages/engine/ecs-engine-bindgen/src/wasm/
|
||||
cp packages/rust/engine/pkg/es_engine_bg.wasm.d.ts packages/engine/ecs-engine-bindgen/src/wasm/
|
||||
|
||||
- name: Bundle runtime files for Tauri
|
||||
run: |
|
||||
cd packages/editor-app
|
||||
cd packages/editor/editor-app
|
||||
node scripts/bundle-runtime.mjs
|
||||
|
||||
- name: Build Tauri app
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
projectPath: packages/editor-app
|
||||
projectPath: packages/editor/editor-app
|
||||
tagName: ${{ github.event_name == 'workflow_dispatch' && format('editor-v{0}', github.event.inputs.version) || github.ref_name }}
|
||||
releaseName: 'ECS Editor v${{ github.event.inputs.version || github.ref_name }}'
|
||||
releaseBody: 'See the assets to download this version and install.'
|
||||
@@ -116,8 +116,8 @@ jobs:
|
||||
with:
|
||||
name: windows-unsigned
|
||||
path: |
|
||||
packages/editor-app/src-tauri/target/release/bundle/nsis/*.exe
|
||||
packages/editor-app/src-tauri/target/release/bundle/msi/*.msi
|
||||
packages/editor/editor-app/src-tauri/target/release/bundle/nsis/*.exe
|
||||
packages/editor/editor-app/src-tauri/target/release/bundle/msi/*.msi
|
||||
retention-days: 1
|
||||
|
||||
# SignPath 代码签名(Windows)
|
||||
@@ -221,7 +221,7 @@ jobs:
|
||||
|
||||
- name: Update version files
|
||||
run: |
|
||||
cd packages/editor-app
|
||||
cd packages/editor/editor-app
|
||||
node -e "const pkg=require('./package.json'); pkg.version='${{ github.event.inputs.version }}'; require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2)+'\n')"
|
||||
node scripts/sync-version.js
|
||||
|
||||
@@ -239,8 +239,8 @@ jobs:
|
||||
This PR updates the editor version after successful release build.
|
||||
|
||||
### Changes
|
||||
- Updated `packages/editor-app/package.json` → `${{ github.event.inputs.version }}`
|
||||
- Updated `packages/editor-app/src-tauri/tauri.conf.json` → `${{ github.event.inputs.version }}`
|
||||
- Updated `packages/editor/editor-app/package.json` → `${{ github.event.inputs.version }}`
|
||||
- Updated `packages/editor/editor-app/src-tauri/tauri.conf.json` → `${{ github.event.inputs.version }}`
|
||||
|
||||
### Release
|
||||
- [GitHub Release](https://github.com/${{ github.repository }}/releases/tag/editor-v${{ github.event.inputs.version }})
|
||||
|
||||
42
.github/workflows/release.yml
vendored
42
.github/workflows/release.yml
vendored
@@ -1,7 +1,6 @@
|
||||
name: Release NPM Packages
|
||||
|
||||
on:
|
||||
# 标签触发:支持 v* 和 {package}-v* 格式
|
||||
# Tag trigger: supports v* and {package}-v* formats
|
||||
push:
|
||||
tags:
|
||||
@@ -15,12 +14,11 @@ on:
|
||||
- 'physics-rapier2d-v*'
|
||||
- 'worker-generator-v*'
|
||||
|
||||
# 保留手动触发选项
|
||||
# Keep manual trigger option
|
||||
# Manual trigger option
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: '选择要发布的包 | Select package to publish'
|
||||
description: 'Select package to publish'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
@@ -33,7 +31,7 @@ on:
|
||||
- physics-rapier2d
|
||||
- worker-generator
|
||||
version_type:
|
||||
description: '版本更新类型 | Version bump type'
|
||||
description: 'Version bump type'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
@@ -61,11 +59,10 @@ jobs:
|
||||
id: parse
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
# 从标签解析包名和版本 | Parse package and version from tag
|
||||
# Parse package and version from tag
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# 解析格式:v1.0.0 或 package-v1.0.0
|
||||
# Parse format: v1.0.0 or package-v1.0.0
|
||||
if [[ "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+.*)$ ]]; then
|
||||
PACKAGE="core"
|
||||
@@ -82,10 +79,9 @@ jobs:
|
||||
echo "package=$PACKAGE" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "mode=tag" >> $GITHUB_OUTPUT
|
||||
echo "📦 Package: $PACKAGE"
|
||||
echo "📌 Version: $VERSION"
|
||||
echo "Package: $PACKAGE"
|
||||
echo "Version: $VERSION"
|
||||
else
|
||||
# 手动触发:从 package.json 读取并 bump 版本
|
||||
# Manual trigger: read from package.json and bump version
|
||||
PACKAGE="${{ github.event.inputs.package }}"
|
||||
echo "package=$PACKAGE" >> $GITHUB_OUTPUT
|
||||
@@ -112,7 +108,6 @@ jobs:
|
||||
PACKAGE="${{ steps.parse.outputs.package }}"
|
||||
EXPECTED_VERSION="${{ steps.parse.outputs.version }}"
|
||||
|
||||
# 获取 package.json 中的版本
|
||||
# Get version from package.json
|
||||
ACTUAL_VERSION=$(node -p "require('./packages/$PACKAGE/package.json').version")
|
||||
|
||||
@@ -125,7 +120,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Version verified: $EXPECTED_VERSION"
|
||||
echo "Version verified: $EXPECTED_VERSION"
|
||||
|
||||
- name: Bump version (manual mode)
|
||||
if: steps.parse.outputs.mode == 'manual'
|
||||
@@ -147,7 +142,7 @@ jobs:
|
||||
node -e "const fs=require('fs'); const pkg=JSON.parse(fs.readFileSync('package.json')); pkg.version='$NEW_VERSION'; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2)+'\n')"
|
||||
|
||||
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "📌 Bumped version: $CURRENT → $NEW_VERSION"
|
||||
echo "Bumped version: $CURRENT -> $NEW_VERSION"
|
||||
|
||||
- name: Set final version
|
||||
id: version
|
||||
@@ -161,7 +156,7 @@ jobs:
|
||||
- name: Build core package (if needed)
|
||||
if: ${{ steps.parse.outputs.package != 'core' && steps.parse.outputs.package != 'node-editor' && steps.parse.outputs.package != 'worker-generator' }}
|
||||
run: |
|
||||
cd packages/core
|
||||
cd packages/framework/core
|
||||
pnpm run build
|
||||
|
||||
- name: Build node-editor package (if needed for blueprint)
|
||||
@@ -188,17 +183,18 @@ jobs:
|
||||
with:
|
||||
tag_name: ${{ steps.parse.outputs.tag }}
|
||||
name: "${{ steps.parse.outputs.package }} v${{ steps.version.outputs.value }}"
|
||||
make_latest: false
|
||||
body: |
|
||||
## 🚀 @esengine/${{ steps.parse.outputs.package }} v${{ steps.version.outputs.value }}
|
||||
## @esengine/${{ steps.parse.outputs.package }} v${{ steps.version.outputs.value }}
|
||||
|
||||
📦 **NPM**: [@esengine/${{ steps.parse.outputs.package }}@${{ steps.version.outputs.value }}](https://www.npmjs.com/package/@esengine/${{ steps.parse.outputs.package }}/v/${{ steps.version.outputs.value }})
|
||||
**NPM**: [@esengine/${{ steps.parse.outputs.package }}@${{ steps.version.outputs.value }}](https://www.npmjs.com/package/@esengine/${{ steps.parse.outputs.package }}/v/${{ steps.version.outputs.value }})
|
||||
|
||||
```bash
|
||||
npm install @esengine/${{ steps.parse.outputs.package }}@${{ steps.version.outputs.value }}
|
||||
```
|
||||
|
||||
---
|
||||
*自动发布 | Auto-released by GitHub Actions*
|
||||
*Auto-released by GitHub Actions*
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -213,16 +209,16 @@ jobs:
|
||||
delete-branch: true
|
||||
title: "chore(${{ steps.parse.outputs.package }}): Release v${{ steps.version.outputs.value }}"
|
||||
body: |
|
||||
## 🚀 Release v${{ steps.version.outputs.value }}
|
||||
## Release v${{ steps.version.outputs.value }}
|
||||
|
||||
此 PR 更新 `@esengine/${{ steps.parse.outputs.package }}` 包的版本号
|
||||
This PR updates `@esengine/${{ steps.parse.outputs.package }}` package version.
|
||||
|
||||
### 变更
|
||||
- ✅ 已发布到 npm: [@esengine/${{ steps.parse.outputs.package }}@${{ steps.version.outputs.value }}](https://www.npmjs.com/package/@esengine/${{ steps.parse.outputs.package }}/v/${{ steps.version.outputs.value }})
|
||||
- ✅ 更新 `packages/${{ steps.parse.outputs.package }}/package.json` → `${{ steps.version.outputs.value }}`
|
||||
### Changes
|
||||
- Published to npm: [@esengine/${{ steps.parse.outputs.package }}@${{ steps.version.outputs.value }}](https://www.npmjs.com/package/@esengine/${{ steps.parse.outputs.package }}/v/${{ steps.version.outputs.value }})
|
||||
- Updated `packages/${{ steps.parse.outputs.package }}/package.json` to `${{ steps.version.outputs.value }}`
|
||||
|
||||
---
|
||||
*此 PR 由发布工作流自动创建*
|
||||
*This PR was automatically created by the release workflow*
|
||||
labels: |
|
||||
release
|
||||
${{ steps.parse.outputs.package }}
|
||||
|
||||
46
.github/workflows/size-limit.yml
vendored
46
.github/workflows/size-limit.yml
vendored
@@ -1,46 +0,0 @@
|
||||
name: Size Limit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
paths:
|
||||
- 'packages/core/src/**'
|
||||
- 'packages/core/package.json'
|
||||
- '.size-limit.json'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
size:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build core package
|
||||
run: |
|
||||
cd packages/core
|
||||
pnpm run build:npm
|
||||
|
||||
- name: Check bundle size
|
||||
uses: andresz1/size-limit-action@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
skip_step: install
|
||||
58
.github/workflows/welcome.yml
vendored
58
.github/workflows/welcome.yml
vendored
@@ -1,58 +0,0 @@
|
||||
name: Welcome
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
welcome:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Welcome new contributors
|
||||
uses: actions/first-interaction@v1.3.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-message: |
|
||||
👋 你好!感谢你提交第一个 issue!
|
||||
|
||||
我们会尽快查看并回复。同时,建议你:
|
||||
- 📚 查看[文档](https://esengine.github.io/ecs-framework/)
|
||||
- 🤖 使用 [AI 文档助手](https://deepwiki.com/esengine/esengine)
|
||||
- 💬 加入 [QQ 交流群](https://jq.qq.com/?_wv=1027&k=29w1Nud6)
|
||||
|
||||
---
|
||||
|
||||
👋 Hello! Thanks for opening your first issue!
|
||||
|
||||
We'll review it as soon as possible. Meanwhile, you might want to:
|
||||
- 📚 Check the [documentation](https://esengine.github.io/ecs-framework/)
|
||||
- 🤖 Use [AI documentation assistant](https://deepwiki.com/esengine/esengine)
|
||||
|
||||
pr-message: |
|
||||
👋 你好!感谢你提交第一个 Pull Request!
|
||||
|
||||
在我们 Review 之前,请确保:
|
||||
- ✅ 代码遵循项目规范
|
||||
- ✅ 通过所有测试
|
||||
- ✅ 更新了相关文档
|
||||
- ✅ Commit 遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范
|
||||
|
||||
查看完整的[贡献指南](https://github.com/esengine/esengine/blob/master/CONTRIBUTING.md)。
|
||||
|
||||
---
|
||||
|
||||
👋 Hello! Thanks for your first Pull Request!
|
||||
|
||||
Before we review, please ensure:
|
||||
- ✅ Code follows project conventions
|
||||
- ✅ All tests pass
|
||||
- ✅ Documentation is updated
|
||||
- ✅ Commits follow [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
|
||||
See the full [Contributing Guide](https://github.com/esengine/esengine/blob/master/CONTRIBUTING.md).
|
||||
289
README.md
289
README.md
@@ -5,7 +5,7 @@
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Cross-platform 2D Game Engine</strong>
|
||||
<strong>Modular Game Framework for TypeScript</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -23,62 +23,58 @@
|
||||
<p align="center">
|
||||
<a href="https://esengine.cn/">Documentation</a> ·
|
||||
<a href="https://esengine.cn/api/README">API Reference</a> ·
|
||||
<a href="https://github.com/esengine/esengine/releases">Download Editor</a> ·
|
||||
<a href="./examples/">Examples</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
## What is ESEngine?
|
||||
|
||||
ESEngine is a cross-platform 2D game engine built from the ground up with modern web technologies. It provides a comprehensive toolset that enables developers to focus on creating games rather than building infrastructure.
|
||||
ESEngine is a collection of **engine-agnostic game development modules** for TypeScript. Use them with Cocos Creator, Laya, Phaser, PixiJS, or any JavaScript game engine.
|
||||
|
||||
Export your games to multiple platforms including web browsers, WeChat Mini Games, and other mini-game platforms from a single codebase.
|
||||
|
||||
## Key Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **ECS Architecture** | Data-driven Entity-Component-System pattern for flexible and cache-friendly game logic |
|
||||
| **High-Performance Rendering** | Rust/WebAssembly 2D renderer with automatic sprite batching and WebGL 2.0 backend |
|
||||
| **Visual Editor** | Cross-platform desktop editor built with Tauri for scene management and asset workflows |
|
||||
| **Modular Design** | Import only what you need - each feature is a standalone package |
|
||||
| **Multi-Platform Export** | Deploy to Web, WeChat Mini Games, and more from one codebase |
|
||||
| **Physics Integration** | 2D physics powered by Rapier with editor visualization |
|
||||
| **Visual Scripting** | Behavior trees and blueprint system for designers |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: TypeScript, Rust, WebAssembly
|
||||
- **Renderer**: WebGL 2.0, WGPU (planned)
|
||||
- **Editor**: Tauri, React, Zustand
|
||||
- **Physics**: Rapier2D
|
||||
- **Build**: pnpm, Turborepo, Rollup
|
||||
|
||||
## License
|
||||
|
||||
ESEngine is **free and open source** under the [MIT License](LICENSE). No royalties, no strings attached.
|
||||
|
||||
## Installation
|
||||
|
||||
### npm
|
||||
The core is a high-performance **ECS (Entity-Component-System)** framework, accompanied by optional modules for AI, networking, physics, and more.
|
||||
|
||||
```bash
|
||||
npm install @esengine/ecs-framework
|
||||
```
|
||||
|
||||
### Editor
|
||||
## Features
|
||||
|
||||
Download pre-built binaries from the [Releases](https://github.com/esengine/esengine/releases) page (Windows, macOS).
|
||||
| Module | Description | Engine Required |
|
||||
|--------|-------------|:---------------:|
|
||||
| **ECS Core** | Entity-Component-System framework with reactive queries | No |
|
||||
| **Behavior Tree** | AI behavior trees with visual editor support | No |
|
||||
| **Blueprint** | Visual scripting system | No |
|
||||
| **FSM** | Finite state machine | No |
|
||||
| **Timer** | Timer and cooldown systems | No |
|
||||
| **Spatial** | Spatial indexing and queries (QuadTree, Grid) | No |
|
||||
| **Pathfinding** | A* and navigation mesh pathfinding | No |
|
||||
| **Network** | Client/server networking with TSRPC | No |
|
||||
|
||||
> All framework modules can be used standalone with any rendering engine.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using CLI (Recommended)
|
||||
|
||||
The easiest way to add ECS to your existing project:
|
||||
|
||||
```bash
|
||||
# In your project directory
|
||||
npx @esengine/cli init
|
||||
```
|
||||
|
||||
The CLI automatically detects your project type (Cocos Creator 2.x/3.x, LayaAir 3.x, or Node.js) and generates the necessary integration code.
|
||||
|
||||
### Manual Setup
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Core, Scene, Entity, Component, EntitySystem,
|
||||
Matcher, Time, ECSComponent, ECSSystem
|
||||
} from '@esengine/ecs-framework';
|
||||
|
||||
// Define components (data only)
|
||||
@ECSComponent('Position')
|
||||
class Position extends Component {
|
||||
x = 0;
|
||||
@@ -91,6 +87,7 @@ class Velocity extends Component {
|
||||
dy = 0;
|
||||
}
|
||||
|
||||
// Define system (logic)
|
||||
@ECSSystem('Movement')
|
||||
class MovementSystem extends EntitySystem {
|
||||
constructor() {
|
||||
@@ -118,7 +115,7 @@ player.addComponent(new Velocity());
|
||||
|
||||
Core.setScene(scene);
|
||||
|
||||
// Game loop
|
||||
// Integrate with your game loop
|
||||
function gameLoop(currentTime: number) {
|
||||
Core.update(currentTime / 1000);
|
||||
requestAnimationFrame(gameLoop);
|
||||
@@ -126,96 +123,132 @@ function gameLoop(currentTime: number) {
|
||||
requestAnimationFrame(gameLoop);
|
||||
```
|
||||
|
||||
## Using with Other Engines
|
||||
|
||||
ESEngine's framework modules are designed to work alongside your preferred rendering engine:
|
||||
|
||||
### With Cocos Creator
|
||||
|
||||
```typescript
|
||||
import { Component as CCComponent, _decorator } from 'cc';
|
||||
import { Core, Scene, Matcher, EntitySystem } from '@esengine/ecs-framework';
|
||||
import { BehaviorTreeExecutionSystem } from '@esengine/behavior-tree';
|
||||
|
||||
const { ccclass } = _decorator;
|
||||
|
||||
@ccclass('GameManager')
|
||||
export class GameManager extends CCComponent {
|
||||
private ecsScene!: Scene;
|
||||
|
||||
start() {
|
||||
Core.create();
|
||||
this.ecsScene = new Scene();
|
||||
|
||||
// Add ECS systems
|
||||
this.ecsScene.addSystem(new BehaviorTreeExecutionSystem());
|
||||
this.ecsScene.addSystem(new MyGameSystem());
|
||||
|
||||
Core.setScene(this.ecsScene);
|
||||
}
|
||||
|
||||
update(dt: number) {
|
||||
Core.update(dt);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Laya 3.x
|
||||
|
||||
```typescript
|
||||
import { Core, Scene } from '@esengine/ecs-framework';
|
||||
import { FSMSystem } from '@esengine/fsm';
|
||||
|
||||
const { regClass } = Laya;
|
||||
|
||||
@regClass()
|
||||
export class ECSManager extends Laya.Script {
|
||||
private ecsScene = new Scene();
|
||||
|
||||
onAwake(): void {
|
||||
Core.create();
|
||||
this.ecsScene.addSystem(new FSMSystem());
|
||||
Core.setScene(this.ecsScene);
|
||||
}
|
||||
|
||||
onUpdate(): void {
|
||||
Core.update(Laya.timer.delta / 1000);
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
Core.destroy();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Packages
|
||||
|
||||
ESEngine is organized as a monorepo with modular packages.
|
||||
### Framework (Engine-Agnostic)
|
||||
|
||||
### Core
|
||||
These packages have **zero rendering dependencies** and work with any engine:
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `@esengine/ecs-framework` | Core ECS framework with entity management, component system, and queries |
|
||||
| `@esengine/math` | Vector, matrix, and mathematical utilities |
|
||||
| `@esengine/engine` | Rust/WASM 2D renderer |
|
||||
| `@esengine/engine-core` | Engine module system and lifecycle management |
|
||||
```bash
|
||||
npm install @esengine/ecs-framework # Core ECS
|
||||
npm install @esengine/behavior-tree # AI behavior trees
|
||||
npm install @esengine/blueprint # Visual scripting
|
||||
npm install @esengine/fsm # State machines
|
||||
npm install @esengine/timer # Timers & cooldowns
|
||||
npm install @esengine/spatial # Spatial indexing
|
||||
npm install @esengine/pathfinding # Pathfinding
|
||||
npm install @esengine/network # Networking
|
||||
```
|
||||
|
||||
### Runtime
|
||||
### ESEngine Runtime (Optional)
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `@esengine/sprite` | 2D sprite rendering and animation |
|
||||
| `@esengine/tilemap` | Tile-based map rendering |
|
||||
| `@esengine/physics-rapier2d` | 2D physics simulation (Rapier) |
|
||||
| `@esengine/behavior-tree` | Behavior tree AI system |
|
||||
| `@esengine/blueprint` | Visual scripting runtime |
|
||||
| `@esengine/camera` | Camera control and management |
|
||||
| `@esengine/audio` | Audio playback |
|
||||
| `@esengine/ui` | UI components |
|
||||
| `@esengine/material-system` | Material and shader system |
|
||||
| `@esengine/asset-system` | Asset loading and management |
|
||||
If you want a complete engine solution with rendering:
|
||||
|
||||
### Editor Extensions
|
||||
| Category | Packages |
|
||||
|----------|----------|
|
||||
| **Core** | `engine-core`, `asset-system`, `material-system` |
|
||||
| **Rendering** | `sprite`, `tilemap`, `particle`, `camera`, `mesh-3d` |
|
||||
| **Physics** | `physics-rapier2d` |
|
||||
| **Platform** | `platform-web`, `platform-wechat` |
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `@esengine/sprite-editor` | Sprite inspector and tools |
|
||||
| `@esengine/tilemap-editor` | Visual tilemap editor |
|
||||
| `@esengine/physics-rapier2d-editor` | Physics collider visualization |
|
||||
| `@esengine/behavior-tree-editor` | Visual behavior tree editor |
|
||||
| `@esengine/blueprint-editor` | Visual scripting editor |
|
||||
| `@esengine/material-editor` | Material editor |
|
||||
### Editor (Optional)
|
||||
|
||||
### Platform
|
||||
A visual editor built with Tauri for scene management:
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `@esengine/platform-common` | Platform abstraction interfaces |
|
||||
| `@esengine/platform-web` | Web browser runtime |
|
||||
| `@esengine/platform-wechat` | WeChat Mini Game runtime |
|
||||
- Download from [Releases](https://github.com/esengine/esengine/releases)
|
||||
- Supports behavior tree editing, tilemap painting, visual scripting
|
||||
|
||||
## Editor
|
||||
## Project Structure
|
||||
|
||||
The ESEngine Editor is a cross-platform desktop application built with Tauri and React.
|
||||
|
||||
### Features
|
||||
|
||||
- Scene hierarchy and entity management
|
||||
- Component inspector with custom property editors
|
||||
- Asset browser with drag-and-drop
|
||||
- Tilemap editor with paint and fill tools
|
||||
- Behavior tree visual editor
|
||||
- Blueprint visual scripting
|
||||
- Material and shader editing
|
||||
- Built-in performance profiler
|
||||
- Localization (English, Chinese)
|
||||
|
||||
### Screenshot
|
||||
|
||||

|
||||
|
||||
## Platform Support
|
||||
|
||||
| Platform | Runtime | Editor |
|
||||
|----------|:-------:|:------:|
|
||||
| Web Browser | ✓ | - |
|
||||
| Windows | - | ✓ |
|
||||
| macOS | - | ✓ |
|
||||
| WeChat Mini Game | In Progress | - |
|
||||
| Playable Ads | Planned | - |
|
||||
| Android | Planned | - |
|
||||
| iOS | Planned | - |
|
||||
```
|
||||
esengine/
|
||||
├── packages/
|
||||
│ ├── framework/ # Engine-agnostic modules (NPM publishable)
|
||||
│ │ ├── core/ # ECS Framework
|
||||
│ │ ├── math/ # Math utilities
|
||||
│ │ ├── behavior-tree/ # AI behavior trees
|
||||
│ │ ├── blueprint/ # Visual scripting
|
||||
│ │ ├── fsm/ # Finite state machine
|
||||
│ │ ├── timer/ # Timer system
|
||||
│ │ ├── spatial/ # Spatial queries
|
||||
│ │ ├── pathfinding/ # Pathfinding
|
||||
│ │ ├── procgen/ # Procedural generation
|
||||
│ │ └── network/ # Networking
|
||||
│ │
|
||||
│ ├── engine/ # ESEngine runtime
|
||||
│ ├── rendering/ # Rendering modules
|
||||
│ ├── physics/ # Physics modules
|
||||
│ ├── editor/ # Visual editor
|
||||
│ └── rust/ # WASM renderer
|
||||
│
|
||||
├── docs/ # Documentation
|
||||
└── examples/ # Examples
|
||||
```
|
||||
|
||||
## Building from Source
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- pnpm 10+
|
||||
- Rust toolchain (for WASM renderer)
|
||||
- wasm-pack
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/esengine/esengine.git
|
||||
cd esengine
|
||||
@@ -223,42 +256,28 @@ cd esengine
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# Optional: Build WASM renderer
|
||||
pnpm build:wasm
|
||||
```
|
||||
# Type check framework packages
|
||||
pnpm type-check:framework
|
||||
|
||||
### Run Editor
|
||||
|
||||
```bash
|
||||
cd packages/editor-app
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
esengine/
|
||||
├── packages/ # Engine packages (runtime, editor, platform)
|
||||
├── docs/ # Documentation source
|
||||
├── examples/ # Example projects
|
||||
├── scripts/ # Build utilities
|
||||
└── thirdparty/ # Third-party dependencies
|
||||
# Run tests
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Getting Started](https://esengine.cn/guide/getting-started.html)
|
||||
- [Architecture Guide](https://esengine.cn/guide/)
|
||||
- [ECS Framework Guide](./packages/framework/core/README.md)
|
||||
- [Behavior Tree Guide](./packages/framework/behavior-tree/README.md)
|
||||
- [API Reference](https://esengine.cn/api/README)
|
||||
|
||||
## Community
|
||||
|
||||
- [GitHub Issues](https://github.com/esengine/esengine/issues) - Bug reports and feature requests
|
||||
- [GitHub Discussions](https://github.com/esengine/esengine/discussions) - Questions and ideas
|
||||
- [Discord](https://discord.gg/gCAgzXFW) - Chat with the community
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Please read the contributing guidelines before submitting a pull request.
|
||||
Contributions are welcome! Please read our contributing guidelines before submitting a pull request.
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
@@ -268,10 +287,10 @@ Contributions are welcome. Please read the contributing guidelines before submit
|
||||
|
||||
## License
|
||||
|
||||
ESEngine is licensed under the [MIT License](LICENSE).
|
||||
ESEngine is licensed under the [MIT License](LICENSE). Free for personal and commercial use.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made with ❤️ by the ESEngine team
|
||||
Made with care by the ESEngine community
|
||||
</p>
|
||||
|
||||
297
README_CN.md
297
README_CN.md
@@ -5,7 +5,7 @@
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>跨平台 2D 游戏引擎</strong>
|
||||
<strong>TypeScript 模块化游戏框架</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -23,62 +23,58 @@
|
||||
<p align="center">
|
||||
<a href="https://esengine.cn/">文档</a> ·
|
||||
<a href="https://esengine.cn/api/README">API 参考</a> ·
|
||||
<a href="https://github.com/esengine/esengine/releases">下载编辑器</a> ·
|
||||
<a href="./examples/">示例</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
## ESEngine 是什么?
|
||||
|
||||
ESEngine 是一款基于现代 Web 技术从零构建的跨平台 2D 游戏引擎。它提供完整的工具集,让开发者专注于游戏创作而非基础设施搭建。
|
||||
ESEngine 是一套**引擎无关的游戏开发模块**,可与 Cocos Creator、Laya、Phaser、PixiJS 等任何 JavaScript 游戏引擎配合使用。
|
||||
|
||||
一套代码即可导出到 Web 浏览器、微信小游戏等多个平台。
|
||||
|
||||
## 核心特性
|
||||
|
||||
| 特性 | 描述 |
|
||||
|-----|------|
|
||||
| **ECS 架构** | 数据驱动的实体-组件-系统模式,提供灵活且缓存友好的游戏逻辑 |
|
||||
| **高性能渲染** | Rust/WebAssembly 2D 渲染器,支持自动精灵批处理和 WebGL 2.0 |
|
||||
| **可视化编辑器** | 基于 Tauri 的跨平台桌面编辑器,支持场景管理和资源工作流 |
|
||||
| **模块化设计** | 按需引入,每个功能都是独立的包 |
|
||||
| **多平台导出** | 一套代码部署到 Web、微信小游戏等平台 |
|
||||
| **物理集成** | 基于 Rapier 的 2D 物理,支持编辑器可视化 |
|
||||
| **可视化脚本** | 行为树和蓝图系统,适合策划使用 |
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **运行时**: TypeScript, Rust, WebAssembly
|
||||
- **渲染器**: WebGL 2.0, WGPU (计划中)
|
||||
- **编辑器**: Tauri, React, Zustand
|
||||
- **物理**: Rapier2D
|
||||
- **构建**: pnpm, Turborepo, Rollup
|
||||
|
||||
## 许可证
|
||||
|
||||
ESEngine **完全免费开源**,采用 [MIT 协议](LICENSE)。无版税,无附加条件。
|
||||
|
||||
## 安装
|
||||
|
||||
### npm
|
||||
核心是一个高性能的 **ECS(实体-组件-系统)** 框架,配套 AI、网络、物理等可选模块。
|
||||
|
||||
```bash
|
||||
npm install @esengine/ecs-framework
|
||||
```
|
||||
|
||||
### 编辑器
|
||||
## 功能模块
|
||||
|
||||
从 [Releases](https://github.com/esengine/esengine/releases) 页面下载预编译版本(支持 Windows、macOS)。
|
||||
| 模块 | 描述 | 需要渲染引擎 |
|
||||
|------|------|:----------:|
|
||||
| **ECS 核心** | 实体-组件-系统框架,支持响应式查询 | 否 |
|
||||
| **行为树** | AI 行为树,支持可视化编辑 | 否 |
|
||||
| **蓝图** | 可视化脚本系统 | 否 |
|
||||
| **状态机** | 有限状态机 | 否 |
|
||||
| **定时器** | 定时器和冷却系统 | 否 |
|
||||
| **空间索引** | 空间查询(四叉树、网格) | 否 |
|
||||
| **寻路** | A* 和导航网格寻路 | 否 |
|
||||
| **网络** | 客户端/服务端网络通信 (TSRPC) | 否 |
|
||||
|
||||
> 所有框架模块都可以独立使用,无需依赖特定渲染引擎。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 使用 CLI(推荐)
|
||||
|
||||
在现有项目中添加 ECS 的最简单方式:
|
||||
|
||||
```bash
|
||||
# 在项目目录中运行
|
||||
npx @esengine/cli init
|
||||
```
|
||||
|
||||
CLI 会自动检测项目类型(Cocos Creator 2.x/3.x、LayaAir 3.x 或 Node.js)并生成相应的集成代码。
|
||||
|
||||
### 手动配置
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Core, Scene, Entity, Component, EntitySystem,
|
||||
Matcher, Time, ECSComponent, ECSSystem
|
||||
} from '@esengine/ecs-framework';
|
||||
|
||||
// 定义组件(纯数据)
|
||||
@ECSComponent('Position')
|
||||
class Position extends Component {
|
||||
x = 0;
|
||||
@@ -91,6 +87,7 @@ class Velocity extends Component {
|
||||
dy = 0;
|
||||
}
|
||||
|
||||
// 定义系统(逻辑)
|
||||
@ECSSystem('Movement')
|
||||
class MovementSystem extends EntitySystem {
|
||||
constructor() {
|
||||
@@ -118,7 +115,7 @@ player.addComponent(new Velocity());
|
||||
|
||||
Core.setScene(scene);
|
||||
|
||||
// 游戏循环
|
||||
// 集成到你的游戏循环
|
||||
function gameLoop(currentTime: number) {
|
||||
Core.update(currentTime / 1000);
|
||||
requestAnimationFrame(gameLoop);
|
||||
@@ -126,96 +123,132 @@ function gameLoop(currentTime: number) {
|
||||
requestAnimationFrame(gameLoop);
|
||||
```
|
||||
|
||||
## 模块
|
||||
## 与其他引擎配合使用
|
||||
|
||||
ESEngine 采用 Monorepo 组织,包含多个模块化包。
|
||||
ESEngine 的框架模块设计为可与你喜欢的渲染引擎配合使用:
|
||||
|
||||
### 核心
|
||||
### 与 Cocos Creator 配合
|
||||
|
||||
| 包名 | 描述 |
|
||||
```typescript
|
||||
import { Component as CCComponent, _decorator } from 'cc';
|
||||
import { Core, Scene, Matcher, EntitySystem } from '@esengine/ecs-framework';
|
||||
import { BehaviorTreeExecutionSystem } from '@esengine/behavior-tree';
|
||||
|
||||
const { ccclass } = _decorator;
|
||||
|
||||
@ccclass('GameManager')
|
||||
export class GameManager extends CCComponent {
|
||||
private ecsScene!: Scene;
|
||||
|
||||
start() {
|
||||
Core.create();
|
||||
this.ecsScene = new Scene();
|
||||
|
||||
// 添加 ECS 系统
|
||||
this.ecsScene.addSystem(new BehaviorTreeExecutionSystem());
|
||||
this.ecsScene.addSystem(new MyGameSystem());
|
||||
|
||||
Core.setScene(this.ecsScene);
|
||||
}
|
||||
|
||||
update(dt: number) {
|
||||
Core.update(dt);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 与 Laya 3.x 配合
|
||||
|
||||
```typescript
|
||||
import { Core, Scene } from '@esengine/ecs-framework';
|
||||
import { FSMSystem } from '@esengine/fsm';
|
||||
|
||||
const { regClass } = Laya;
|
||||
|
||||
@regClass()
|
||||
export class ECSManager extends Laya.Script {
|
||||
private ecsScene = new Scene();
|
||||
|
||||
onAwake(): void {
|
||||
Core.create();
|
||||
this.ecsScene.addSystem(new FSMSystem());
|
||||
Core.setScene(this.ecsScene);
|
||||
}
|
||||
|
||||
onUpdate(): void {
|
||||
Core.update(Laya.timer.delta / 1000);
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
Core.destroy();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 包列表
|
||||
|
||||
### 框架包(引擎无关)
|
||||
|
||||
这些包**零渲染依赖**,可与任何引擎配合使用:
|
||||
|
||||
```bash
|
||||
npm install @esengine/ecs-framework # ECS 核心
|
||||
npm install @esengine/behavior-tree # AI 行为树
|
||||
npm install @esengine/blueprint # 可视化脚本
|
||||
npm install @esengine/fsm # 状态机
|
||||
npm install @esengine/timer # 定时器和冷却
|
||||
npm install @esengine/spatial # 空间索引
|
||||
npm install @esengine/pathfinding # 寻路
|
||||
npm install @esengine/network # 网络
|
||||
```
|
||||
|
||||
### ESEngine 运行时(可选)
|
||||
|
||||
如果你需要完整的引擎解决方案:
|
||||
|
||||
| 分类 | 包名 |
|
||||
|------|------|
|
||||
| `@esengine/ecs-framework` | ECS 框架核心,包含实体管理、组件系统和查询 |
|
||||
| `@esengine/math` | 向量、矩阵和数学工具 |
|
||||
| `@esengine/engine` | Rust/WASM 2D 渲染器 |
|
||||
| `@esengine/engine-core` | 引擎模块系统和生命周期管理 |
|
||||
| **核心** | `engine-core`, `asset-system`, `material-system` |
|
||||
| **渲染** | `sprite`, `tilemap`, `particle`, `camera`, `mesh-3d` |
|
||||
| **物理** | `physics-rapier2d` |
|
||||
| **平台** | `platform-web`, `platform-wechat` |
|
||||
|
||||
### 运行时
|
||||
### 编辑器(可选)
|
||||
|
||||
| 包名 | 描述 |
|
||||
|------|------|
|
||||
| `@esengine/sprite` | 2D 精灵渲染和动画 |
|
||||
| `@esengine/tilemap` | Tilemap 渲染 |
|
||||
| `@esengine/physics-rapier2d` | 2D 物理模拟 (Rapier) |
|
||||
| `@esengine/behavior-tree` | 行为树 AI 系统 |
|
||||
| `@esengine/blueprint` | 可视化脚本运行时 |
|
||||
| `@esengine/camera` | 相机控制和管理 |
|
||||
| `@esengine/audio` | 音频播放 |
|
||||
| `@esengine/ui` | UI 组件 |
|
||||
| `@esengine/material-system` | 材质和着色器系统 |
|
||||
| `@esengine/asset-system` | 资源加载和管理 |
|
||||
基于 Tauri 构建的可视化编辑器:
|
||||
|
||||
### 编辑器扩展
|
||||
- 从 [Releases](https://github.com/esengine/esengine/releases) 下载
|
||||
- 支持行为树编辑、Tilemap 绘制、可视化脚本
|
||||
|
||||
| 包名 | 描述 |
|
||||
|------|------|
|
||||
| `@esengine/sprite-editor` | 精灵检视器和工具 |
|
||||
| `@esengine/tilemap-editor` | 可视化 Tilemap 编辑器 |
|
||||
| `@esengine/physics-rapier2d-editor` | 物理碰撞体可视化 |
|
||||
| `@esengine/behavior-tree-editor` | 可视化行为树编辑器 |
|
||||
| `@esengine/blueprint-editor` | 可视化脚本编辑器 |
|
||||
| `@esengine/material-editor` | 材质编辑器 |
|
||||
## 项目结构
|
||||
|
||||
### 平台
|
||||
|
||||
| 包名 | 描述 |
|
||||
|------|------|
|
||||
| `@esengine/platform-common` | 平台抽象接口 |
|
||||
| `@esengine/platform-web` | Web 浏览器运行时 |
|
||||
| `@esengine/platform-wechat` | 微信小游戏运行时 |
|
||||
|
||||
## 编辑器
|
||||
|
||||
ESEngine 编辑器是基于 Tauri 和 React 构建的跨平台桌面应用。
|
||||
|
||||
### 功能
|
||||
|
||||
- 场景层级和实体管理
|
||||
- 组件检视器,支持自定义属性编辑器
|
||||
- 资源浏览器,支持拖放
|
||||
- Tilemap 编辑器,支持绘制和填充工具
|
||||
- 行为树可视化编辑器
|
||||
- 蓝图可视化脚本
|
||||
- 材质和着色器编辑
|
||||
- 内置性能分析器
|
||||
- 多语言支持(英文、中文)
|
||||
|
||||
### 截图
|
||||
|
||||

|
||||
|
||||
## 平台支持
|
||||
|
||||
| 平台 | 运行时 | 编辑器 |
|
||||
|------|:------:|:------:|
|
||||
| Web 浏览器 | ✓ | - |
|
||||
| Windows | - | ✓ |
|
||||
| macOS | - | ✓ |
|
||||
| 微信小游戏 | 开发中 | - |
|
||||
| Playable 可玩广告 | 计划中 | - |
|
||||
| Android | 计划中 | - |
|
||||
| iOS | 计划中 | - |
|
||||
```
|
||||
esengine/
|
||||
├── packages/
|
||||
│ ├── framework/ # 引擎无关模块(可发布到 NPM)
|
||||
│ │ ├── core/ # ECS 框架
|
||||
│ │ ├── math/ # 数学工具
|
||||
│ │ ├── behavior-tree/ # AI 行为树
|
||||
│ │ ├── blueprint/ # 可视化脚本
|
||||
│ │ ├── fsm/ # 有限状态机
|
||||
│ │ ├── timer/ # 定时器系统
|
||||
│ │ ├── spatial/ # 空间查询
|
||||
│ │ ├── pathfinding/ # 寻路
|
||||
│ │ ├── procgen/ # 程序化生成
|
||||
│ │ └── network/ # 网络
|
||||
│ │
|
||||
│ ├── engine/ # ESEngine 运行时
|
||||
│ ├── rendering/ # 渲染模块
|
||||
│ ├── physics/ # 物理模块
|
||||
│ ├── editor/ # 可视化编辑器
|
||||
│ └── rust/ # WASM 渲染器
|
||||
│
|
||||
├── docs/ # 文档
|
||||
└── examples/ # 示例
|
||||
```
|
||||
|
||||
## 从源码构建
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Node.js 18+
|
||||
- pnpm 10+
|
||||
- Rust 工具链(用于 WASM 渲染器)
|
||||
- wasm-pack
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
git clone https://github.com/esengine/esengine.git
|
||||
cd esengine
|
||||
@@ -223,43 +256,29 @@ cd esengine
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# 可选:构建 WASM 渲染器
|
||||
pnpm build:wasm
|
||||
```
|
||||
# 框架包类型检查
|
||||
pnpm type-check:framework
|
||||
|
||||
### 运行编辑器
|
||||
|
||||
```bash
|
||||
cd packages/editor-app
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
esengine/
|
||||
├── packages/ # 引擎包(运行时、编辑器、平台)
|
||||
├── docs/ # 文档源码
|
||||
├── examples/ # 示例项目
|
||||
├── scripts/ # 构建工具
|
||||
└── thirdparty/ # 第三方依赖
|
||||
# 运行测试
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
- [快速入门](https://esengine.cn/guide/getting-started.html)
|
||||
- [架构指南](https://esengine.cn/guide/)
|
||||
- [ECS 框架指南](./packages/framework/core/README.md)
|
||||
- [行为树指南](./packages/framework/behavior-tree/README.md)
|
||||
- [API 参考](https://esengine.cn/api/README)
|
||||
|
||||
## 社区
|
||||
|
||||
- [QQ 交流群](https://jq.qq.com/?_wv=1027&k=29w1Nud6) - 中文社区
|
||||
- [Discord](https://discord.gg/gCAgzXFW) - 国际社区
|
||||
- [GitHub Issues](https://github.com/esengine/esengine/issues) - Bug 反馈和功能建议
|
||||
- [GitHub Discussions](https://github.com/esengine/esengine/discussions) - 问题和想法
|
||||
- [QQ 交流群](https://jq.qq.com/?_wv=1027&k=29w1Nud6) - 中文社区
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎贡献代码。提交 PR 前请阅读贡献指南。
|
||||
欢迎贡献代码!提交 PR 前请阅读贡献指南。
|
||||
|
||||
1. Fork 仓库
|
||||
2. 创建功能分支 (`git checkout -b feature/amazing-feature`)
|
||||
@@ -269,10 +288,10 @@ esengine/
|
||||
|
||||
## 许可证
|
||||
|
||||
ESEngine 基于 [MIT 协议](LICENSE) 开源。
|
||||
ESEngine 基于 [MIT 协议](LICENSE) 开源,个人和商业使用均免费。
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
由 ESEngine 团队用 ❤️ 打造
|
||||
由 ESEngine 社区用心打造
|
||||
</p>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const corePackageJson = JSON.parse(
|
||||
readFileSync(join(__dirname, '../../packages/core/package.json'), 'utf-8')
|
||||
readFileSync(join(__dirname, '../../packages/framework/core/package.json'), 'utf-8')
|
||||
)
|
||||
|
||||
// Import i18n messages
|
||||
@@ -49,21 +49,6 @@ function createSidebar(t, prefix = '') {
|
||||
{ text: t.sidebar.persistentEntity, link: `${prefix}/guide/persistent-entity` }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: t.sidebar.behaviorTree,
|
||||
link: `${prefix}/guide/behavior-tree/`,
|
||||
items: [
|
||||
{ text: t.sidebar.btGettingStarted, link: `${prefix}/guide/behavior-tree/getting-started` },
|
||||
{ text: t.sidebar.btCoreConcepts, link: `${prefix}/guide/behavior-tree/core-concepts` },
|
||||
{ text: t.sidebar.btEditorGuide, link: `${prefix}/guide/behavior-tree/editor-guide` },
|
||||
{ text: t.sidebar.btEditorWorkflow, link: `${prefix}/guide/behavior-tree/editor-workflow` },
|
||||
{ text: t.sidebar.btCustomActions, link: `${prefix}/guide/behavior-tree/custom-actions` },
|
||||
{ text: t.sidebar.btCocosIntegration, link: `${prefix}/guide/behavior-tree/cocos-integration` },
|
||||
{ text: t.sidebar.btLayaIntegration, link: `${prefix}/guide/behavior-tree/laya-integration` },
|
||||
{ text: t.sidebar.btAdvancedUsage, link: `${prefix}/guide/behavior-tree/advanced-usage` },
|
||||
{ text: t.sidebar.btBestPractices, link: `${prefix}/guide/behavior-tree/best-practices` }
|
||||
]
|
||||
},
|
||||
{ text: t.sidebar.serialization, link: `${prefix}/guide/serialization` },
|
||||
{ text: t.sidebar.eventSystem, link: `${prefix}/guide/event-system` },
|
||||
{ text: t.sidebar.timeAndTimers, link: `${prefix}/guide/time-and-timers` },
|
||||
@@ -89,6 +74,64 @@ function createSidebar(t, prefix = '') {
|
||||
]
|
||||
}
|
||||
],
|
||||
// 模块总览侧边栏 | Modules overview sidebar
|
||||
[`${prefix}/modules/`]: [
|
||||
{
|
||||
text: t.sidebar.modulesOverview,
|
||||
link: `${prefix}/modules/`,
|
||||
items: [
|
||||
{
|
||||
text: t.sidebar.aiModules,
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: t.sidebar.behaviorTree, link: `${prefix}/modules/behavior-tree/` },
|
||||
{ text: t.sidebar.fsm, link: `${prefix}/modules/fsm/` }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: t.sidebar.gameplayModules,
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: t.sidebar.timer, link: `${prefix}/modules/timer/` },
|
||||
{ text: t.sidebar.spatial, link: `${prefix}/modules/spatial/` },
|
||||
{ text: t.sidebar.pathfinding, link: `${prefix}/modules/pathfinding/` }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: t.sidebar.toolModules,
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: t.sidebar.blueprint, link: `${prefix}/modules/blueprint/` },
|
||||
{ text: t.sidebar.procgen, link: `${prefix}/modules/procgen/` }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: t.sidebar.networkModules,
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: t.sidebar.network, link: `${prefix}/modules/network/` }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
// 行为树模块侧边栏 | Behavior tree module sidebar
|
||||
[`${prefix}/modules/behavior-tree/`]: [
|
||||
{
|
||||
text: t.sidebar.behaviorTree,
|
||||
items: [
|
||||
{ text: t.sidebar.btGettingStarted, link: `${prefix}/modules/behavior-tree/getting-started` },
|
||||
{ text: t.sidebar.btCoreConcepts, link: `${prefix}/modules/behavior-tree/core-concepts` },
|
||||
{ text: t.sidebar.btEditorGuide, link: `${prefix}/modules/behavior-tree/editor-guide` },
|
||||
{ text: t.sidebar.btEditorWorkflow, link: `${prefix}/modules/behavior-tree/editor-workflow` },
|
||||
{ text: t.sidebar.btCustomActions, link: `${prefix}/modules/behavior-tree/custom-actions` },
|
||||
{ text: t.sidebar.btCocosIntegration, link: `${prefix}/modules/behavior-tree/cocos-integration` },
|
||||
{ text: t.sidebar.btLayaIntegration, link: `${prefix}/modules/behavior-tree/laya-integration` },
|
||||
{ text: t.sidebar.btAdvancedUsage, link: `${prefix}/modules/behavior-tree/advanced-usage` },
|
||||
{ text: t.sidebar.btBestPractices, link: `${prefix}/modules/behavior-tree/best-practices` }
|
||||
]
|
||||
}
|
||||
],
|
||||
[`${prefix}/examples/`]: [
|
||||
{
|
||||
text: t.sidebar.examples,
|
||||
@@ -173,6 +216,7 @@ function createNav(t, prefix = '') {
|
||||
{ text: t.nav.home, link: `${prefix}/` },
|
||||
{ text: t.nav.quickStart, link: `${prefix}/guide/getting-started` },
|
||||
{ text: t.nav.guide, link: `${prefix}/guide/` },
|
||||
{ text: t.nav.modules, link: `${prefix}/modules/` },
|
||||
{ text: t.nav.api, link: `${prefix}/api/README` },
|
||||
{
|
||||
text: t.nav.examples,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"home": "Home",
|
||||
"quickStart": "Quick Start",
|
||||
"guide": "Guide",
|
||||
"modules": "Modules",
|
||||
"api": "API",
|
||||
"examples": "Examples",
|
||||
"workerDemo": "Worker System Demo",
|
||||
@@ -54,7 +55,26 @@
|
||||
"utilities": "Utilities",
|
||||
"interfaces": "Interfaces",
|
||||
"decorators": "Decorators",
|
||||
"enums": "Enums"
|
||||
"enums": "Enums",
|
||||
"modulesOverview": "Modules Overview",
|
||||
"aiModules": "AI Modules",
|
||||
"gameplayModules": "Gameplay",
|
||||
"toolModules": "Tools",
|
||||
"networkModules": "Network",
|
||||
"fsm": "State Machine (FSM)",
|
||||
"fsmOverview": "Overview",
|
||||
"timer": "Timer System",
|
||||
"timerOverview": "Overview",
|
||||
"spatial": "Spatial Index",
|
||||
"spatialOverview": "Overview",
|
||||
"pathfinding": "Pathfinding",
|
||||
"pathfindingOverview": "Overview",
|
||||
"blueprint": "Visual Scripting",
|
||||
"blueprintOverview": "Overview",
|
||||
"procgen": "Procedural Generation",
|
||||
"procgenOverview": "Overview",
|
||||
"network": "Network Sync",
|
||||
"networkOverview": "Overview"
|
||||
},
|
||||
"home": {
|
||||
"title": "ESEngine - High-performance TypeScript ECS Framework",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"home": "首页",
|
||||
"quickStart": "快速开始",
|
||||
"guide": "指南",
|
||||
"modules": "模块",
|
||||
"api": "API",
|
||||
"examples": "示例",
|
||||
"workerDemo": "Worker系统演示",
|
||||
@@ -54,7 +55,26 @@
|
||||
"utilities": "工具类",
|
||||
"interfaces": "接口",
|
||||
"decorators": "装饰器",
|
||||
"enums": "枚举"
|
||||
"enums": "枚举",
|
||||
"modulesOverview": "模块总览",
|
||||
"aiModules": "AI 模块",
|
||||
"gameplayModules": "游戏逻辑",
|
||||
"toolModules": "工具模块",
|
||||
"networkModules": "网络模块",
|
||||
"fsm": "状态机 (FSM)",
|
||||
"fsmOverview": "概述",
|
||||
"timer": "定时器系统",
|
||||
"timerOverview": "概述",
|
||||
"spatial": "空间索引",
|
||||
"spatialOverview": "概述",
|
||||
"pathfinding": "寻路系统",
|
||||
"pathfindingOverview": "概述",
|
||||
"blueprint": "可视化脚本",
|
||||
"blueprintOverview": "概述",
|
||||
"procgen": "程序化生成",
|
||||
"procgenOverview": "概述",
|
||||
"network": "网络同步",
|
||||
"networkOverview": "概述"
|
||||
},
|
||||
"home": {
|
||||
"title": "ESEngine - 高性能 TypeScript ECS 框架",
|
||||
|
||||
@@ -2,23 +2,24 @@
|
||||
color-scheme: dark;
|
||||
--vp-nav-height: 64px;
|
||||
|
||||
--es-bg-base: #1e1e1e;
|
||||
--es-bg-elevated: #252526;
|
||||
--es-bg-overlay: #2d2d2d;
|
||||
--es-bg-input: #3c3c3c;
|
||||
--es-bg-inset: #181818;
|
||||
--es-bg-base: #1a1a1a;
|
||||
--es-bg-elevated: #222222;
|
||||
--es-bg-overlay: #2a2a2a;
|
||||
--es-bg-input: #333333;
|
||||
--es-bg-inset: #151515;
|
||||
--es-bg-hover: #2a2d2e;
|
||||
--es-bg-active: #37373d;
|
||||
--es-bg-sidebar: #262626;
|
||||
--es-bg-card: #2a2a2a;
|
||||
--es-bg-header: #2d2d2d;
|
||||
--es-bg-sidebar: #1e1e1e;
|
||||
--es-bg-card: #242424;
|
||||
--es-bg-header: #1e1e1e;
|
||||
|
||||
--es-text-primary: #cccccc;
|
||||
--es-text-secondary: #9d9d9d;
|
||||
--es-text-tertiary: #6a6a6a;
|
||||
/* 提高文字对比度 | Improve text contrast */
|
||||
--es-text-primary: #e0e0e0;
|
||||
--es-text-secondary: #b0b0b0;
|
||||
--es-text-tertiary: #888888;
|
||||
--es-text-inverse: #ffffff;
|
||||
--es-text-muted: #aaaaaa;
|
||||
--es-text-dim: #6a6a6a;
|
||||
--es-text-muted: #c0c0c0;
|
||||
--es-text-dim: #888888;
|
||||
|
||||
--es-font-xs: 11px;
|
||||
--es-font-sm: 12px;
|
||||
|
||||
@@ -4,6 +4,49 @@
|
||||
|
||||
---
|
||||
|
||||
## v2.4.2 (2025-12-25)
|
||||
|
||||
### Features
|
||||
|
||||
- **IncrementalSerializer 实体过滤**: 增量序列化支持 `entityFilter` 选项 (#335)
|
||||
- 创建快照时可按条件过滤实体
|
||||
- 支持按标签、组件类型等自定义过滤逻辑
|
||||
- 适用于只同步部分实体的场景(如只同步玩家)
|
||||
|
||||
```typescript
|
||||
// 只快照玩家实体
|
||||
const snapshot = IncrementalSerializer.createSnapshot(scene, {
|
||||
entityFilter: (entity) => entity.tag === PLAYER_TAG
|
||||
});
|
||||
|
||||
// 只快照有特定组件的实体
|
||||
const snapshot = IncrementalSerializer.createSnapshot(scene, {
|
||||
entityFilter: (entity) => entity.hasComponent(PlayerMarker)
|
||||
});
|
||||
```
|
||||
|
||||
### Refactor
|
||||
|
||||
- 优化 `PlatformWorkerPool` 代码规范,提取为独立模块 (#335)
|
||||
- 优化 `WorkerEntitySystem` 实现,改进代码结构 (#334)
|
||||
- 代码规范化与依赖清理 (#317)
|
||||
- 代码结构优化,添加 `GlobalTypes.ts` 统一类型定义 (#316)
|
||||
|
||||
---
|
||||
|
||||
## v2.4.1 (2025-12-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 修复 `IntervalSystem` 时间累加 bug,间隔计时更加准确
|
||||
- 修复 Cocos Creator 兼容性问题,类型导出更完整
|
||||
|
||||
### Documentation
|
||||
|
||||
- 新增 `Core.paused` 属性文档说明
|
||||
|
||||
---
|
||||
|
||||
## v2.4.0 (2025-12-15)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -4,6 +4,49 @@ This document records the version update history of the `@esengine/ecs-framework
|
||||
|
||||
---
|
||||
|
||||
## v2.4.2 (2025-12-25)
|
||||
|
||||
### Features
|
||||
|
||||
- **IncrementalSerializer Entity Filter**: Incremental serialization supports `entityFilter` option (#335)
|
||||
- Filter entities by condition when creating snapshots
|
||||
- Support custom filter logic by tag, component type, etc.
|
||||
- Suitable for scenarios that only sync partial entities (e.g., only sync players)
|
||||
|
||||
```typescript
|
||||
// Only snapshot player entities
|
||||
const snapshot = IncrementalSerializer.createSnapshot(scene, {
|
||||
entityFilter: (entity) => entity.tag === PLAYER_TAG
|
||||
});
|
||||
|
||||
// Only snapshot entities with specific component
|
||||
const snapshot = IncrementalSerializer.createSnapshot(scene, {
|
||||
entityFilter: (entity) => entity.hasComponent(PlayerMarker)
|
||||
});
|
||||
```
|
||||
|
||||
### Refactor
|
||||
|
||||
- Optimize `PlatformWorkerPool` code style, extract as standalone module (#335)
|
||||
- Optimize `WorkerEntitySystem` implementation, improve code structure (#334)
|
||||
- Code standardization and dependency cleanup (#317)
|
||||
- Code structure optimization, add `GlobalTypes.ts` for unified type definitions (#316)
|
||||
|
||||
---
|
||||
|
||||
## v2.4.1 (2025-12-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix `IntervalSystem` time accumulation bug, interval timing is now more accurate
|
||||
- Fix Cocos Creator compatibility issue, more complete type exports
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add `Core.paused` property documentation
|
||||
|
||||
---
|
||||
|
||||
## v2.4.0 (2025-12-15)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -4,7 +4,24 @@ This guide will help you get started with ECS Framework, from installation to cr
|
||||
|
||||
## Installation
|
||||
|
||||
### NPM Installation
|
||||
### Using CLI (Recommended)
|
||||
|
||||
The easiest way to add ECS to your existing project:
|
||||
|
||||
```bash
|
||||
# In your project directory
|
||||
npx @esengine/cli init
|
||||
```
|
||||
|
||||
The CLI automatically detects your project type (Cocos Creator 2.x/3.x, LayaAir 3.x, or Node.js) and generates the necessary integration code, including:
|
||||
|
||||
- `ECSManager` component/script - Manages ECS lifecycle
|
||||
- Example components and systems - Helps you get started quickly
|
||||
- Automatic dependency installation
|
||||
|
||||
### Manual NPM Installation
|
||||
|
||||
If you prefer manual configuration:
|
||||
|
||||
```bash
|
||||
# Using npm
|
||||
@@ -333,27 +350,39 @@ function gameLoop(deltaTime: number) {
|
||||
|
||||
## Game Engine Integration
|
||||
|
||||
### Laya Engine Integration
|
||||
### Laya 3.x Engine Integration
|
||||
|
||||
Using `Laya.Script` component to manage ECS lifecycle is recommended:
|
||||
|
||||
```typescript
|
||||
import { Stage } from "laya/display/Stage";
|
||||
import { Laya } from "Laya";
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { Core, Scene } from '@esengine/ecs-framework';
|
||||
|
||||
// Initialize Laya
|
||||
Laya.init(800, 600).then(() => {
|
||||
// Initialize ECS
|
||||
Core.create(true);
|
||||
Core.setScene(new GameScene());
|
||||
const { regClass } = Laya;
|
||||
|
||||
// Start game loop
|
||||
Laya.timer.frameLoop(1, this, () => {
|
||||
const deltaTime = Laya.timer.delta / 1000;
|
||||
Core.update(deltaTime); // Auto-updates global services and scene
|
||||
});
|
||||
});
|
||||
@regClass()
|
||||
export class ECSManager extends Laya.Script {
|
||||
private ecsScene = new GameScene();
|
||||
|
||||
onAwake(): void {
|
||||
// Initialize ECS
|
||||
Core.create({ debug: true });
|
||||
Core.setScene(this.ecsScene);
|
||||
}
|
||||
|
||||
onUpdate(): void {
|
||||
// Auto-updates global services and scene
|
||||
Core.update(Laya.timer.delta / 1000);
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
// Cleanup resources
|
||||
Core.destroy();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In Laya IDE, attach the `ECSManager` script to a node in your scene.
|
||||
|
||||
### Cocos Creator Integration
|
||||
|
||||
```typescript
|
||||
|
||||
404
docs/en/modules/blueprint/index.md
Normal file
404
docs/en/modules/blueprint/index.md
Normal file
@@ -0,0 +1,404 @@
|
||||
# Blueprint Visual Scripting
|
||||
|
||||
`@esengine/blueprint` provides a full-featured visual scripting system supporting node-based programming, event-driven execution, and blueprint composition.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/blueprint
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createBlueprintSystem,
|
||||
createBlueprintComponentData,
|
||||
NodeRegistry,
|
||||
RegisterNode
|
||||
} from '@esengine/blueprint';
|
||||
|
||||
// Create blueprint system
|
||||
const blueprintSystem = createBlueprintSystem(scene);
|
||||
|
||||
// Load blueprint asset
|
||||
const blueprint = await loadBlueprintAsset('player.bp');
|
||||
|
||||
// Create blueprint component data
|
||||
const componentData = createBlueprintComponentData();
|
||||
componentData.blueprintAsset = blueprint;
|
||||
|
||||
// Update in game loop
|
||||
function gameLoop(dt: number) {
|
||||
blueprintSystem.process(entities, dt);
|
||||
}
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 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
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
316
docs/en/modules/fsm/index.md
Normal file
316
docs/en/modules/fsm/index.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# State Machine (FSM)
|
||||
|
||||
`@esengine/fsm` provides a type-safe finite state machine implementation for characters, AI, or any scenario requiring state management.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/fsm
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { createStateMachine } from '@esengine/fsm';
|
||||
|
||||
// Define state types
|
||||
type PlayerState = 'idle' | 'walk' | 'run' | 'jump';
|
||||
|
||||
// Create state machine
|
||||
const fsm = createStateMachine<PlayerState>('idle');
|
||||
|
||||
// Define states with callbacks
|
||||
fsm.defineState('idle', {
|
||||
onEnter: (ctx, from) => console.log(`Entered idle from ${from}`),
|
||||
onExit: (ctx, to) => console.log(`Exiting idle to ${to}`),
|
||||
onUpdate: (ctx, dt) => { /* Update every frame */ }
|
||||
});
|
||||
|
||||
fsm.defineState('walk', {
|
||||
onEnter: () => console.log('Started walking')
|
||||
});
|
||||
|
||||
// Manual transition
|
||||
fsm.transition('walk');
|
||||
|
||||
console.log(fsm.current); // 'walk'
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### State Configuration
|
||||
|
||||
Each state can be configured with the following callbacks:
|
||||
|
||||
```typescript
|
||||
interface StateConfig<TState, TContext> {
|
||||
name: TState; // State name
|
||||
onEnter?: (context: TContext, from: TState | null) => void; // Enter callback
|
||||
onExit?: (context: TContext, to: TState) => void; // Exit callback
|
||||
onUpdate?: (context: TContext, deltaTime: number) => void; // Update callback
|
||||
tags?: string[]; // State tags
|
||||
metadata?: Record<string, unknown>; // Metadata
|
||||
}
|
||||
```
|
||||
|
||||
### Transition Conditions
|
||||
|
||||
Define conditional state transitions:
|
||||
|
||||
```typescript
|
||||
interface Context {
|
||||
isMoving: boolean;
|
||||
isRunning: boolean;
|
||||
isGrounded: boolean;
|
||||
}
|
||||
|
||||
const fsm = createStateMachine<PlayerState, Context>('idle', {
|
||||
context: { isMoving: false, isRunning: false, isGrounded: true }
|
||||
});
|
||||
|
||||
// Define transition conditions
|
||||
fsm.defineTransition('idle', 'walk', (ctx) => ctx.isMoving);
|
||||
fsm.defineTransition('walk', 'run', (ctx) => ctx.isRunning);
|
||||
fsm.defineTransition('walk', 'idle', (ctx) => !ctx.isMoving);
|
||||
|
||||
// Automatically evaluate and execute matching transitions
|
||||
fsm.evaluateTransitions();
|
||||
```
|
||||
|
||||
### Transition Priority
|
||||
|
||||
When multiple transitions are valid, higher priority executes first:
|
||||
|
||||
```typescript
|
||||
// Higher priority number = higher priority
|
||||
fsm.defineTransition('idle', 'attack', (ctx) => ctx.isAttacking, 10);
|
||||
fsm.defineTransition('idle', 'walk', (ctx) => ctx.isMoving, 1);
|
||||
|
||||
// If both conditions are met, 'attack' (priority 10) is tried first
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### createStateMachine
|
||||
|
||||
```typescript
|
||||
function createStateMachine<TState extends string, TContext = unknown>(
|
||||
initialState: TState,
|
||||
options?: StateMachineOptions<TContext>
|
||||
): IStateMachine<TState, TContext>
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `initialState` - Initial state
|
||||
- `options.context` - Context object, accessible in callbacks
|
||||
- `options.maxHistorySize` - Maximum history entries (default 100)
|
||||
- `options.enableHistory` - Enable history tracking (default true)
|
||||
|
||||
### State Machine Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `current` | `TState` | Current state |
|
||||
| `previous` | `TState \| null` | Previous state |
|
||||
| `context` | `TContext` | Context object |
|
||||
| `isTransitioning` | `boolean` | Whether currently transitioning |
|
||||
| `currentStateDuration` | `number` | Current state duration (ms) |
|
||||
|
||||
### State Machine Methods
|
||||
|
||||
#### State Definition
|
||||
|
||||
```typescript
|
||||
// Define state
|
||||
fsm.defineState('idle', {
|
||||
onEnter: (ctx, from) => {},
|
||||
onExit: (ctx, to) => {},
|
||||
onUpdate: (ctx, dt) => {}
|
||||
});
|
||||
|
||||
// Check if state exists
|
||||
fsm.hasState('idle'); // true
|
||||
|
||||
// Get state configuration
|
||||
fsm.getStateConfig('idle');
|
||||
|
||||
// Get all states
|
||||
fsm.getStates(); // ['idle', 'walk', ...]
|
||||
```
|
||||
|
||||
#### Transition Operations
|
||||
|
||||
```typescript
|
||||
// Define transition
|
||||
fsm.defineTransition('idle', 'walk', condition, priority);
|
||||
|
||||
// Remove transition
|
||||
fsm.removeTransition('idle', 'walk');
|
||||
|
||||
// Get transitions from state
|
||||
fsm.getTransitionsFrom('idle');
|
||||
|
||||
// Check if transition is possible
|
||||
fsm.canTransition('walk'); // true/false
|
||||
|
||||
// Manual transition
|
||||
fsm.transition('walk');
|
||||
|
||||
// Force transition (ignore conditions)
|
||||
fsm.transition('walk', true);
|
||||
|
||||
// Auto-evaluate transition conditions
|
||||
fsm.evaluateTransitions();
|
||||
```
|
||||
|
||||
#### Lifecycle
|
||||
|
||||
```typescript
|
||||
// Update state machine (calls current state's onUpdate)
|
||||
fsm.update(deltaTime);
|
||||
|
||||
// Reset state machine
|
||||
fsm.reset(); // Reset to current state
|
||||
fsm.reset('idle'); // Reset to specified state
|
||||
```
|
||||
|
||||
#### Event Listeners
|
||||
|
||||
```typescript
|
||||
// Listen to entering specific state
|
||||
const unsubscribe = fsm.onEnter('walk', (from) => {
|
||||
console.log(`Entered walk from ${from}`);
|
||||
});
|
||||
|
||||
// Listen to exiting specific state
|
||||
fsm.onExit('walk', (to) => {
|
||||
console.log(`Exiting walk to ${to}`);
|
||||
});
|
||||
|
||||
// Listen to any state change
|
||||
fsm.onChange((event) => {
|
||||
console.log(`${event.from} -> ${event.to} at ${event.timestamp}`);
|
||||
});
|
||||
|
||||
// Unsubscribe
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
#### Debugging
|
||||
|
||||
```typescript
|
||||
// Get state history
|
||||
const history = fsm.getHistory();
|
||||
// [{ from: 'idle', to: 'walk', timestamp: 1234567890 }, ...]
|
||||
|
||||
// Clear history
|
||||
fsm.clearHistory();
|
||||
|
||||
// Get debug info
|
||||
const info = fsm.getDebugInfo();
|
||||
// { current, previous, duration, stateCount, transitionCount, historySize }
|
||||
```
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Character State Machine
|
||||
|
||||
```typescript
|
||||
import { createStateMachine } from '@esengine/fsm';
|
||||
|
||||
type CharacterState = 'idle' | 'walk' | 'run' | 'jump' | 'fall' | 'attack';
|
||||
|
||||
interface CharacterContext {
|
||||
velocity: { x: number; y: number };
|
||||
isGrounded: boolean;
|
||||
isAttacking: boolean;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
const characterFSM = createStateMachine<CharacterState, CharacterContext>('idle', {
|
||||
context: {
|
||||
velocity: { x: 0, y: 0 },
|
||||
isGrounded: true,
|
||||
isAttacking: false,
|
||||
speed: 0
|
||||
}
|
||||
});
|
||||
|
||||
// Define states
|
||||
characterFSM.defineState('idle', {
|
||||
onEnter: (ctx) => { ctx.speed = 0; }
|
||||
});
|
||||
|
||||
characterFSM.defineState('walk', {
|
||||
onEnter: (ctx) => { ctx.speed = 100; }
|
||||
});
|
||||
|
||||
characterFSM.defineState('run', {
|
||||
onEnter: (ctx) => { ctx.speed = 200; }
|
||||
});
|
||||
|
||||
// Define transitions
|
||||
characterFSM.defineTransition('idle', 'walk', (ctx) => Math.abs(ctx.velocity.x) > 0);
|
||||
characterFSM.defineTransition('walk', 'idle', (ctx) => ctx.velocity.x === 0);
|
||||
characterFSM.defineTransition('walk', 'run', (ctx) => Math.abs(ctx.velocity.x) > 150);
|
||||
|
||||
// Jump has highest priority
|
||||
characterFSM.defineTransition('idle', 'jump', (ctx) => !ctx.isGrounded, 10);
|
||||
characterFSM.defineTransition('walk', 'jump', (ctx) => !ctx.isGrounded, 10);
|
||||
|
||||
// Game loop usage
|
||||
function gameUpdate(dt: number) {
|
||||
// Update context
|
||||
characterFSM.context.velocity.x = getInputVelocity();
|
||||
characterFSM.context.isGrounded = checkGrounded();
|
||||
|
||||
// Evaluate transitions
|
||||
characterFSM.evaluateTransitions();
|
||||
|
||||
// Update current state
|
||||
characterFSM.update(dt);
|
||||
}
|
||||
```
|
||||
|
||||
### ECS Integration
|
||||
|
||||
```typescript
|
||||
import { Component, EntitySystem, Matcher } from '@esengine/ecs-framework';
|
||||
import { createStateMachine, type IStateMachine } from '@esengine/fsm';
|
||||
|
||||
// State machine component
|
||||
class FSMComponent extends Component {
|
||||
fsm: IStateMachine<string>;
|
||||
|
||||
constructor(initialState: string) {
|
||||
super();
|
||||
this.fsm = createStateMachine(initialState);
|
||||
}
|
||||
}
|
||||
|
||||
// State machine system
|
||||
class FSMSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.all(FSMComponent));
|
||||
}
|
||||
|
||||
protected processEntity(entity: Entity, dt: number): void {
|
||||
const fsmComp = entity.getComponent(FSMComponent);
|
||||
fsmComp.fsm.evaluateTransitions();
|
||||
fsmComp.fsm.update(dt);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Blueprint Nodes
|
||||
|
||||
The FSM module provides blueprint nodes for visual scripting:
|
||||
|
||||
- `GetCurrentState` - Get current state
|
||||
- `TransitionTo` - Transition to specified state
|
||||
- `CanTransition` - Check if transition is possible
|
||||
- `IsInState` - Check if in specified state
|
||||
- `WasInState` - Check if was ever in specified state
|
||||
- `GetStateDuration` - Get state duration
|
||||
- `EvaluateTransitions` - Evaluate transition conditions
|
||||
- `ResetStateMachine` - Reset state machine
|
||||
54
docs/en/modules/index.md
Normal file
54
docs/en/modules/index.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Modules
|
||||
|
||||
ESEngine provides a rich set of modules that can be imported as needed.
|
||||
|
||||
## Module List
|
||||
|
||||
### AI Modules
|
||||
|
||||
| Module | Package | Description |
|
||||
|--------|---------|-------------|
|
||||
| [Behavior Tree](/en/modules/behavior-tree/) | `@esengine/behavior-tree` | AI behavior tree with visual editor |
|
||||
| [State Machine](/en/modules/fsm/) | `@esengine/fsm` | Finite state machine for character/AI states |
|
||||
|
||||
### Gameplay
|
||||
|
||||
| Module | Package | Description |
|
||||
|--------|---------|-------------|
|
||||
| [Timer](/en/modules/timer/) | `@esengine/timer` | Timer and cooldown system |
|
||||
| [Spatial](/en/modules/spatial/) | `@esengine/spatial` | Spatial queries, AOI management |
|
||||
| [Pathfinding](/en/modules/pathfinding/) | `@esengine/pathfinding` | A* pathfinding, NavMesh navigation |
|
||||
|
||||
### Tools
|
||||
|
||||
| Module | Package | Description |
|
||||
|--------|---------|-------------|
|
||||
| [Blueprint](/en/modules/blueprint/) | `@esengine/blueprint` | Visual scripting system |
|
||||
| [Procgen](/en/modules/procgen/) | `@esengine/procgen` | Noise functions, random utilities |
|
||||
|
||||
### Network
|
||||
|
||||
| Module | Package | Description |
|
||||
|--------|---------|-------------|
|
||||
| [Network](/en/modules/network/) | `@esengine/network` | Multiplayer game networking |
|
||||
|
||||
## Installation
|
||||
|
||||
All modules can be installed independently:
|
||||
|
||||
```bash
|
||||
# Install a single module
|
||||
npm install @esengine/behavior-tree
|
||||
|
||||
# Or use CLI to add to existing project
|
||||
npx @esengine/cli add behavior-tree
|
||||
```
|
||||
|
||||
## Platform Compatibility
|
||||
|
||||
All modules are pure TypeScript and compatible with:
|
||||
|
||||
- Cocos Creator 3.x
|
||||
- Laya 3.x
|
||||
- Node.js
|
||||
- Browser
|
||||
727
docs/en/modules/network/index.md
Normal file
727
docs/en/modules/network/index.md
Normal file
@@ -0,0 +1,727 @@
|
||||
# Network System
|
||||
|
||||
`@esengine/network` provides a TSRPC-based client-server network synchronization solution for multiplayer games, including entity synchronization, input handling, and state interpolation.
|
||||
|
||||
## Overview
|
||||
|
||||
The network module consists of three packages:
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `@esengine/network` | Client-side ECS plugin |
|
||||
| `@esengine/network-protocols` | Shared protocol definitions |
|
||||
| `@esengine/network-server` | Server-side implementation |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Client
|
||||
npm install @esengine/network
|
||||
|
||||
# Server
|
||||
npm install @esengine/network-server
|
||||
```
|
||||
|
||||
## Quick Setup with CLI
|
||||
|
||||
We recommend using ESEngine CLI to quickly create a complete game server project:
|
||||
|
||||
```bash
|
||||
# Create project directory
|
||||
mkdir my-game-server && cd my-game-server
|
||||
npm init -y
|
||||
|
||||
# Initialize Node.js server with CLI
|
||||
npx @esengine/cli init -p nodejs
|
||||
```
|
||||
|
||||
The CLI will generate the following project structure:
|
||||
|
||||
```
|
||||
my-game-server/
|
||||
├── src/
|
||||
│ ├── index.ts # Entry point
|
||||
│ ├── server/
|
||||
│ │ └── GameServer.ts # Network server configuration
|
||||
│ └── game/
|
||||
│ ├── Game.ts # ECS game class
|
||||
│ ├── scenes/
|
||||
│ │ └── MainScene.ts # Main scene
|
||||
│ ├── components/ # ECS components
|
||||
│ │ ├── PositionComponent.ts
|
||||
│ │ └── VelocityComponent.ts
|
||||
│ └── systems/ # ECS systems
|
||||
│ └── MovementSystem.ts
|
||||
├── tsconfig.json
|
||||
├── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
Start the server:
|
||||
|
||||
```bash
|
||||
# Development mode (hot reload)
|
||||
npm run dev
|
||||
|
||||
# Production mode
|
||||
npm run start
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Client
|
||||
|
||||
```typescript
|
||||
import { Core, Scene } from '@esengine/ecs-framework';
|
||||
import {
|
||||
NetworkPlugin,
|
||||
NetworkIdentity,
|
||||
NetworkTransform
|
||||
} from '@esengine/network';
|
||||
|
||||
// Define game scene
|
||||
class GameScene extends Scene {
|
||||
initialize(): void {
|
||||
this.name = 'Game';
|
||||
// Network systems are automatically added by NetworkPlugin
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Core
|
||||
Core.create({ debug: false });
|
||||
const scene = new GameScene();
|
||||
Core.setScene(scene);
|
||||
|
||||
// Install network plugin
|
||||
const networkPlugin = new NetworkPlugin();
|
||||
await Core.installPlugin(networkPlugin);
|
||||
|
||||
// Register prefab factory
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
const identity = entity.addComponent(new NetworkIdentity());
|
||||
identity.netId = spawn.netId;
|
||||
identity.ownerId = spawn.ownerId;
|
||||
identity.isLocalPlayer = spawn.ownerId === networkPlugin.networkService.localClientId;
|
||||
|
||||
entity.addComponent(new NetworkTransform());
|
||||
return entity;
|
||||
});
|
||||
|
||||
// Connect to server
|
||||
const success = await networkPlugin.connect('ws://localhost:3000', 'PlayerName');
|
||||
if (success) {
|
||||
console.log('Connected!');
|
||||
}
|
||||
|
||||
// Game loop
|
||||
function gameLoop(dt: number) {
|
||||
Core.update(dt);
|
||||
}
|
||||
|
||||
// Disconnect
|
||||
await networkPlugin.disconnect();
|
||||
```
|
||||
|
||||
### Server
|
||||
|
||||
After creating a server project with CLI, the generated code already configures GameServer:
|
||||
|
||||
```typescript
|
||||
import { GameServer } from '@esengine/network-server';
|
||||
|
||||
const server = new GameServer({
|
||||
port: 3000,
|
||||
roomConfig: {
|
||||
maxPlayers: 16,
|
||||
tickRate: 20
|
||||
}
|
||||
});
|
||||
|
||||
await server.start();
|
||||
console.log('Server started on ws://localhost:3000');
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Client Server
|
||||
┌────────────────┐ ┌────────────────┐
|
||||
│ NetworkPlugin │◄──── WS ────► │ GameServer │
|
||||
│ ├─ Service │ │ ├─ Room │
|
||||
│ ├─ SyncSystem │ │ └─ Players │
|
||||
│ ├─ SpawnSystem │ └────────────────┘
|
||||
│ └─ InputSystem │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
#### NetworkIdentity
|
||||
|
||||
Network identity component, required for every networked entity:
|
||||
|
||||
```typescript
|
||||
class NetworkIdentity extends Component {
|
||||
netId: number; // Network unique ID
|
||||
ownerId: number; // Owner client ID
|
||||
bIsLocalPlayer: boolean; // Whether local player
|
||||
bHasAuthority: boolean; // Whether has control authority
|
||||
}
|
||||
```
|
||||
|
||||
#### NetworkTransform
|
||||
|
||||
Network transform component for position and rotation sync:
|
||||
|
||||
```typescript
|
||||
class NetworkTransform extends Component {
|
||||
position: { x: number; y: number };
|
||||
rotation: number;
|
||||
velocity: { x: number; y: number };
|
||||
}
|
||||
```
|
||||
|
||||
### Systems
|
||||
|
||||
#### NetworkSyncSystem
|
||||
|
||||
Handles server state synchronization and interpolation:
|
||||
|
||||
- Receives server state snapshots
|
||||
- Stores states in snapshot buffer
|
||||
- Performs interpolation for remote entities
|
||||
|
||||
#### NetworkSpawnSystem
|
||||
|
||||
Handles network entity spawning and despawning:
|
||||
|
||||
- Listens for Spawn/Despawn messages
|
||||
- Creates entities using registered prefab factories
|
||||
- Manages networked entity lifecycle
|
||||
|
||||
#### NetworkInputSystem
|
||||
|
||||
Handles local player input sending:
|
||||
|
||||
- Collects local player input
|
||||
- Sends input to server
|
||||
- Supports movement and action inputs
|
||||
|
||||
## API Reference
|
||||
|
||||
### NetworkPlugin
|
||||
|
||||
```typescript
|
||||
class NetworkPlugin {
|
||||
constructor(config: INetworkPluginConfig);
|
||||
|
||||
// Install plugin
|
||||
install(services: ServiceContainer): void;
|
||||
|
||||
// Connect to server
|
||||
connect(playerName: string, roomId?: string): Promise<void>;
|
||||
|
||||
// Disconnect
|
||||
disconnect(): void;
|
||||
|
||||
// Register prefab factory
|
||||
registerPrefab(prefab: string, factory: PrefabFactory): void;
|
||||
|
||||
// Properties
|
||||
readonly localPlayerId: number | null;
|
||||
readonly isConnected: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
|----------|------|----------|-------------|
|
||||
| `serverUrl` | `string` | Yes | WebSocket server URL |
|
||||
|
||||
### NetworkService
|
||||
|
||||
Network service managing WebSocket connections:
|
||||
|
||||
```typescript
|
||||
class NetworkService {
|
||||
// Connection state
|
||||
readonly state: ENetworkState;
|
||||
readonly isConnected: boolean;
|
||||
readonly clientId: number | null;
|
||||
readonly roomId: string | null;
|
||||
|
||||
// Connection control
|
||||
connect(serverUrl: string): Promise<void>;
|
||||
disconnect(): void;
|
||||
|
||||
// Join room
|
||||
join(playerName: string, roomId?: string): Promise<ResJoin>;
|
||||
|
||||
// Send input
|
||||
sendInput(input: IPlayerInput): void;
|
||||
|
||||
// Event callbacks
|
||||
setCallbacks(callbacks: Partial<INetworkCallbacks>): void;
|
||||
}
|
||||
```
|
||||
|
||||
**Network state enum:**
|
||||
|
||||
```typescript
|
||||
enum ENetworkState {
|
||||
Disconnected = 'disconnected',
|
||||
Connecting = 'connecting',
|
||||
Connected = 'connected',
|
||||
Joining = 'joining',
|
||||
Joined = 'joined'
|
||||
}
|
||||
```
|
||||
|
||||
**Callbacks interface:**
|
||||
|
||||
```typescript
|
||||
interface INetworkCallbacks {
|
||||
onConnected?: () => void;
|
||||
onDisconnected?: () => void;
|
||||
onJoined?: (clientId: number, roomId: string) => void;
|
||||
onSync?: (msg: MsgSync) => void;
|
||||
onSpawn?: (msg: MsgSpawn) => void;
|
||||
onDespawn?: (msg: MsgDespawn) => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Prefab Factory
|
||||
|
||||
```typescript
|
||||
type PrefabFactory = (scene: Scene, spawn: MsgSpawn) => Entity;
|
||||
```
|
||||
|
||||
Register prefab factories for network entity creation:
|
||||
|
||||
```typescript
|
||||
networkPlugin.registerPrefab('enemy', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`enemy_${spawn.netId}`);
|
||||
|
||||
const identity = entity.addComponent(new NetworkIdentity());
|
||||
identity.netId = spawn.netId;
|
||||
identity.ownerId = spawn.ownerId;
|
||||
|
||||
entity.addComponent(new NetworkTransform());
|
||||
entity.addComponent(new EnemyComponent());
|
||||
return entity;
|
||||
});
|
||||
```
|
||||
|
||||
### Input System
|
||||
|
||||
#### NetworkInputSystem
|
||||
|
||||
```typescript
|
||||
class NetworkInputSystem extends EntitySystem {
|
||||
// Add movement input
|
||||
addMoveInput(x: number, y: number): void;
|
||||
|
||||
// Add action input
|
||||
addActionInput(action: string): void;
|
||||
|
||||
// Clear input
|
||||
clearInput(): void;
|
||||
}
|
||||
```
|
||||
|
||||
Usage example:
|
||||
|
||||
```typescript
|
||||
// Send input via NetworkPlugin (recommended)
|
||||
networkPlugin.sendMoveInput(0, 1); // Movement
|
||||
networkPlugin.sendActionInput('jump'); // Action
|
||||
|
||||
// Or use inputSystem directly
|
||||
const inputSystem = networkPlugin.inputSystem;
|
||||
if (keyboard.isPressed('W')) {
|
||||
inputSystem.addMoveInput(0, 1);
|
||||
}
|
||||
if (keyboard.isPressed('Space')) {
|
||||
inputSystem.addActionInput('jump');
|
||||
}
|
||||
```
|
||||
|
||||
## State Synchronization
|
||||
|
||||
### Snapshot Buffer
|
||||
|
||||
Stores server state snapshots for interpolation:
|
||||
|
||||
```typescript
|
||||
import { createSnapshotBuffer, type IStateSnapshot } from '@esengine/network';
|
||||
|
||||
const buffer = createSnapshotBuffer<IStateSnapshot>({
|
||||
maxSnapshots: 30, // Max snapshots
|
||||
interpolationDelay: 100 // Interpolation delay (ms)
|
||||
});
|
||||
|
||||
// Add snapshot
|
||||
buffer.addSnapshot({
|
||||
time: serverTime,
|
||||
entities: states
|
||||
});
|
||||
|
||||
// Get interpolated state
|
||||
const interpolated = buffer.getInterpolatedState(clientTime);
|
||||
```
|
||||
|
||||
### Transform Interpolators
|
||||
|
||||
#### Linear Interpolator
|
||||
|
||||
```typescript
|
||||
import { createTransformInterpolator } from '@esengine/network';
|
||||
|
||||
const interpolator = createTransformInterpolator();
|
||||
|
||||
// Add state
|
||||
interpolator.addState(time, { x: 0, y: 0, rotation: 0 });
|
||||
|
||||
// Get interpolated result
|
||||
const state = interpolator.getInterpolatedState(currentTime);
|
||||
```
|
||||
|
||||
#### Hermite Interpolator
|
||||
|
||||
Uses Hermite splines for smoother interpolation:
|
||||
|
||||
```typescript
|
||||
import { createHermiteTransformInterpolator } from '@esengine/network';
|
||||
|
||||
const interpolator = createHermiteTransformInterpolator({
|
||||
bufferSize: 10
|
||||
});
|
||||
|
||||
// Add state with velocity
|
||||
interpolator.addState(time, {
|
||||
x: 100,
|
||||
y: 200,
|
||||
rotation: 0,
|
||||
vx: 5,
|
||||
vy: 0
|
||||
});
|
||||
|
||||
// Get smooth interpolated result
|
||||
const state = interpolator.getInterpolatedState(currentTime);
|
||||
```
|
||||
|
||||
### Client Prediction
|
||||
|
||||
Implement client-side prediction with server reconciliation:
|
||||
|
||||
```typescript
|
||||
import { createClientPrediction } from '@esengine/network';
|
||||
|
||||
const prediction = createClientPrediction({
|
||||
maxPredictedInputs: 60,
|
||||
reconciliationThreshold: 0.1
|
||||
});
|
||||
|
||||
// Predict input
|
||||
const seq = prediction.predict(inputState, currentState, (state, input) => {
|
||||
// Apply input to state
|
||||
return applyInput(state, input);
|
||||
});
|
||||
|
||||
// Server reconciliation
|
||||
const corrected = prediction.reconcile(
|
||||
serverState,
|
||||
serverSeq,
|
||||
(state, input) => applyInput(state, input)
|
||||
);
|
||||
```
|
||||
|
||||
## Server Side
|
||||
|
||||
### GameServer
|
||||
|
||||
```typescript
|
||||
import { GameServer } from '@esengine/network-server';
|
||||
|
||||
const server = new GameServer({
|
||||
port: 3000,
|
||||
roomConfig: {
|
||||
maxPlayers: 16, // Max players per room
|
||||
tickRate: 20 // Sync rate (Hz)
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
await server.start();
|
||||
|
||||
// Get room
|
||||
const room = server.getOrCreateRoom('room-id');
|
||||
|
||||
// Stop server
|
||||
await server.stop();
|
||||
```
|
||||
|
||||
### Room
|
||||
|
||||
```typescript
|
||||
class Room {
|
||||
readonly id: string;
|
||||
readonly playerCount: number;
|
||||
readonly isFull: boolean;
|
||||
|
||||
// Add player
|
||||
addPlayer(name: string, connection: Connection): IPlayer | null;
|
||||
|
||||
// Remove player
|
||||
removePlayer(clientId: number): void;
|
||||
|
||||
// Get player
|
||||
getPlayer(clientId: number): IPlayer | undefined;
|
||||
|
||||
// Handle input
|
||||
handleInput(clientId: number, input: IPlayerInput): void;
|
||||
|
||||
// Destroy room
|
||||
destroy(): void;
|
||||
}
|
||||
```
|
||||
|
||||
**Player interface:**
|
||||
|
||||
```typescript
|
||||
interface IPlayer {
|
||||
clientId: number; // Client ID
|
||||
name: string; // Player name
|
||||
connection: Connection; // Connection object
|
||||
netId: number; // Network entity ID
|
||||
}
|
||||
```
|
||||
|
||||
## Protocol Types
|
||||
|
||||
### Message Types
|
||||
|
||||
```typescript
|
||||
// State sync message
|
||||
interface MsgSync {
|
||||
time: number;
|
||||
entities: IEntityState[];
|
||||
}
|
||||
|
||||
// Entity state
|
||||
interface IEntityState {
|
||||
netId: number;
|
||||
pos?: Vec2;
|
||||
rot?: number;
|
||||
}
|
||||
|
||||
// Spawn message
|
||||
interface MsgSpawn {
|
||||
netId: number;
|
||||
ownerId: number;
|
||||
prefab: string;
|
||||
pos: Vec2;
|
||||
rot: number;
|
||||
}
|
||||
|
||||
// Despawn message
|
||||
interface MsgDespawn {
|
||||
netId: number;
|
||||
}
|
||||
|
||||
// Input message
|
||||
interface MsgInput {
|
||||
input: IPlayerInput;
|
||||
}
|
||||
|
||||
// Player input
|
||||
interface IPlayerInput {
|
||||
seq?: number;
|
||||
moveDir?: Vec2;
|
||||
actions?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### API Types
|
||||
|
||||
```typescript
|
||||
// Join request
|
||||
interface ReqJoin {
|
||||
playerName: string;
|
||||
roomId?: string;
|
||||
}
|
||||
|
||||
// Join response
|
||||
interface ResJoin {
|
||||
clientId: number;
|
||||
roomId: string;
|
||||
playerCount: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Blueprint Nodes
|
||||
|
||||
The network module provides blueprint nodes for visual scripting:
|
||||
|
||||
- `IsLocalPlayer` - Check if entity is local player
|
||||
- `IsServer` - Check if running on server
|
||||
- `HasAuthority` - Check if has authority over entity
|
||||
- `GetNetworkId` - Get entity's network ID
|
||||
- `GetLocalPlayerId` - Get local player ID
|
||||
|
||||
## Service Tokens
|
||||
|
||||
For dependency injection:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
NetworkServiceToken,
|
||||
NetworkSyncSystemToken,
|
||||
NetworkSpawnSystemToken,
|
||||
NetworkInputSystemToken
|
||||
} from '@esengine/network';
|
||||
|
||||
// Get service
|
||||
const networkService = services.get(NetworkServiceToken);
|
||||
```
|
||||
|
||||
## Practical Example
|
||||
|
||||
### Complete Multiplayer Client
|
||||
|
||||
```typescript
|
||||
import { Core, Scene, EntitySystem, Matcher, Entity } from '@esengine/ecs-framework';
|
||||
import {
|
||||
NetworkPlugin,
|
||||
NetworkIdentity,
|
||||
NetworkTransform
|
||||
} from '@esengine/network';
|
||||
|
||||
// Define game scene
|
||||
class GameScene extends Scene {
|
||||
initialize(): void {
|
||||
this.name = 'MultiplayerGame';
|
||||
// Network systems are automatically added by NetworkPlugin
|
||||
// Add custom systems
|
||||
this.addSystem(new LocalInputHandler());
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
async function initGame() {
|
||||
Core.create({ debug: false });
|
||||
|
||||
const scene = new GameScene();
|
||||
Core.setScene(scene);
|
||||
|
||||
// Install network plugin
|
||||
const networkPlugin = new NetworkPlugin();
|
||||
await Core.installPlugin(networkPlugin);
|
||||
|
||||
// Register player prefab
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
const identity = entity.addComponent(new NetworkIdentity());
|
||||
identity.netId = spawn.netId;
|
||||
identity.ownerId = spawn.ownerId;
|
||||
identity.isLocalPlayer = spawn.ownerId === networkPlugin.networkService.localClientId;
|
||||
|
||||
entity.addComponent(new NetworkTransform());
|
||||
|
||||
// If local player, add input marker
|
||||
if (identity.isLocalPlayer) {
|
||||
entity.addComponent(new LocalInputComponent());
|
||||
}
|
||||
|
||||
return entity;
|
||||
});
|
||||
|
||||
// Connect to server
|
||||
const success = await networkPlugin.connect('ws://localhost:3000', 'Player1');
|
||||
if (success) {
|
||||
console.log('Connected!');
|
||||
} else {
|
||||
console.error('Connection failed');
|
||||
}
|
||||
|
||||
return networkPlugin;
|
||||
}
|
||||
|
||||
// Game loop
|
||||
function gameLoop(deltaTime: number) {
|
||||
Core.update(deltaTime);
|
||||
}
|
||||
|
||||
initGame();
|
||||
```
|
||||
|
||||
### Handling Input
|
||||
|
||||
```typescript
|
||||
class LocalInputHandler extends EntitySystem {
|
||||
private _networkPlugin: NetworkPlugin | null = null;
|
||||
|
||||
constructor() {
|
||||
super(Matcher.empty().all(NetworkIdentity, LocalInputComponent));
|
||||
}
|
||||
|
||||
protected onAddedToScene(): void {
|
||||
// Get NetworkPlugin reference
|
||||
this._networkPlugin = Core.getPlugin(NetworkPlugin);
|
||||
}
|
||||
|
||||
protected processEntity(entity: Entity, dt: number): void {
|
||||
if (!this._networkPlugin) return;
|
||||
|
||||
const identity = entity.getComponent(NetworkIdentity)!;
|
||||
if (!identity.isLocalPlayer) return;
|
||||
|
||||
// Read keyboard input
|
||||
let moveX = 0;
|
||||
let moveY = 0;
|
||||
|
||||
if (keyboard.isPressed('A')) moveX -= 1;
|
||||
if (keyboard.isPressed('D')) moveX += 1;
|
||||
if (keyboard.isPressed('W')) moveY += 1;
|
||||
if (keyboard.isPressed('S')) moveY -= 1;
|
||||
|
||||
if (moveX !== 0 || moveY !== 0) {
|
||||
this._networkPlugin.sendMoveInput(moveX, moveY);
|
||||
}
|
||||
|
||||
if (keyboard.isJustPressed('Space')) {
|
||||
this._networkPlugin.sendActionInput('jump');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Set appropriate sync rate**: Choose `tickRate` based on game type, action games typically need 20-60 Hz
|
||||
|
||||
2. **Use interpolation delay**: Set appropriate `interpolationDelay` to balance latency and smoothness
|
||||
|
||||
3. **Client prediction**: Use client-side prediction for local players to reduce input lag
|
||||
|
||||
4. **Prefab management**: Register prefab factories for each networked entity type
|
||||
|
||||
5. **Authority checks**: Use `bHasAuthority` to check entity control permissions
|
||||
|
||||
6. **Connection state**: Monitor connection state changes, handle reconnection
|
||||
|
||||
```typescript
|
||||
networkService.setCallbacks({
|
||||
onConnected: () => console.log('Connected'),
|
||||
onDisconnected: () => {
|
||||
console.log('Disconnected');
|
||||
// Handle reconnection logic
|
||||
}
|
||||
});
|
||||
```
|
||||
299
docs/en/modules/pathfinding/index.md
Normal file
299
docs/en/modules/pathfinding/index.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# Pathfinding System
|
||||
|
||||
`@esengine/pathfinding` provides a complete 2D pathfinding solution including A* algorithm, grid maps, navigation meshes, and path smoothing.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/pathfinding
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Grid Map Pathfinding
|
||||
|
||||
```typescript
|
||||
import { createGridMap, createAStarPathfinder } from '@esengine/pathfinding';
|
||||
|
||||
// Create 20x20 grid
|
||||
const grid = createGridMap(20, 20);
|
||||
|
||||
// Set obstacles
|
||||
grid.setWalkable(5, 5, false);
|
||||
grid.setWalkable(5, 6, false);
|
||||
|
||||
// Create pathfinder
|
||||
const pathfinder = createAStarPathfinder(grid);
|
||||
|
||||
// Find path
|
||||
const result = pathfinder.findPath(0, 0, 15, 15);
|
||||
|
||||
if (result.found) {
|
||||
console.log('Path found!');
|
||||
console.log('Path:', result.path);
|
||||
console.log('Cost:', result.cost);
|
||||
}
|
||||
```
|
||||
|
||||
### NavMesh Pathfinding
|
||||
|
||||
```typescript
|
||||
import { createNavMesh } from '@esengine/pathfinding';
|
||||
|
||||
const navmesh = createNavMesh();
|
||||
|
||||
// Add polygon areas
|
||||
navmesh.addPolygon([
|
||||
{ x: 0, y: 0 }, { x: 10, y: 0 },
|
||||
{ x: 10, y: 10 }, { x: 0, y: 10 }
|
||||
]);
|
||||
|
||||
navmesh.addPolygon([
|
||||
{ x: 10, y: 0 }, { x: 20, y: 0 },
|
||||
{ x: 20, y: 10 }, { x: 10, y: 10 }
|
||||
]);
|
||||
|
||||
// Auto-build connections
|
||||
navmesh.build();
|
||||
|
||||
// Find path
|
||||
const result = navmesh.findPath(1, 1, 18, 8);
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### IPathResult
|
||||
|
||||
```typescript
|
||||
interface IPathResult {
|
||||
readonly found: boolean; // Path found
|
||||
readonly path: readonly IPoint[];// Path points
|
||||
readonly cost: number; // Total cost
|
||||
readonly nodesSearched: number; // Nodes searched
|
||||
}
|
||||
```
|
||||
|
||||
### IPathfindingOptions
|
||||
|
||||
```typescript
|
||||
interface IPathfindingOptions {
|
||||
maxNodes?: number; // Max search nodes (default 10000)
|
||||
heuristicWeight?: number; // Heuristic weight (>1 faster but may be suboptimal)
|
||||
allowDiagonal?: boolean; // Allow diagonal movement (default true)
|
||||
avoidCorners?: boolean; // Avoid corner cutting (default true)
|
||||
}
|
||||
```
|
||||
|
||||
## Heuristic Functions
|
||||
|
||||
| Function | Use Case | Description |
|
||||
|----------|----------|-------------|
|
||||
| `manhattanDistance` | 4-directional | Manhattan distance |
|
||||
| `euclideanDistance` | Any direction | Euclidean distance |
|
||||
| `chebyshevDistance` | 8-directional | Diagonal cost = 1 |
|
||||
| `octileDistance` | 8-directional | Diagonal cost = √2 (default) |
|
||||
|
||||
## Grid Map API
|
||||
|
||||
### createGridMap
|
||||
|
||||
```typescript
|
||||
function createGridMap(
|
||||
width: number,
|
||||
height: number,
|
||||
options?: IGridMapOptions
|
||||
): GridMap
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `allowDiagonal` | `boolean` | `true` | Allow diagonal movement |
|
||||
| `diagonalCost` | `number` | `√2` | Diagonal movement cost |
|
||||
| `avoidCorners` | `boolean` | `true` | Avoid corner cutting |
|
||||
| `heuristic` | `HeuristicFunction` | `octileDistance` | Heuristic function |
|
||||
|
||||
### Map Operations
|
||||
|
||||
```typescript
|
||||
// Check/set walkability
|
||||
grid.isWalkable(x, y);
|
||||
grid.setWalkable(x, y, false);
|
||||
|
||||
// Set movement cost (e.g., swamp, sand)
|
||||
grid.setCost(x, y, 2);
|
||||
|
||||
// Set rectangle region
|
||||
grid.setRectWalkable(0, 0, 5, 5, false);
|
||||
|
||||
// Load from array (0=walkable, non-0=blocked)
|
||||
grid.loadFromArray([
|
||||
[0, 0, 0, 1, 0],
|
||||
[0, 1, 0, 1, 0]
|
||||
]);
|
||||
|
||||
// Load from string (.=walkable, #=blocked)
|
||||
grid.loadFromString(`
|
||||
.....
|
||||
.#.#.
|
||||
`);
|
||||
|
||||
// Export and reset
|
||||
console.log(grid.toString());
|
||||
grid.reset();
|
||||
```
|
||||
|
||||
## A* Pathfinder API
|
||||
|
||||
```typescript
|
||||
const pathfinder = createAStarPathfinder(grid);
|
||||
|
||||
const result = pathfinder.findPath(
|
||||
startX, startY,
|
||||
endX, endY,
|
||||
{ maxNodes: 5000, heuristicWeight: 1.5 }
|
||||
);
|
||||
|
||||
// Pathfinder is reusable
|
||||
pathfinder.findPath(0, 0, 10, 10);
|
||||
pathfinder.findPath(5, 5, 15, 15);
|
||||
```
|
||||
|
||||
## NavMesh API
|
||||
|
||||
```typescript
|
||||
const navmesh = createNavMesh();
|
||||
|
||||
// Add convex polygons
|
||||
const id1 = navmesh.addPolygon(vertices1);
|
||||
const id2 = navmesh.addPolygon(vertices2);
|
||||
|
||||
// Auto-detect shared edges
|
||||
navmesh.build();
|
||||
|
||||
// Or manually set connections
|
||||
navmesh.setConnection(id1, id2, {
|
||||
left: { x: 10, y: 0 },
|
||||
right: { x: 10, y: 10 }
|
||||
});
|
||||
|
||||
// Query and pathfind
|
||||
const polygon = navmesh.findPolygonAt(5, 5);
|
||||
navmesh.isWalkable(5, 5);
|
||||
const result = navmesh.findPath(1, 1, 18, 8);
|
||||
```
|
||||
|
||||
## Path Smoothing
|
||||
|
||||
### Line of Sight Smoothing
|
||||
|
||||
Remove unnecessary waypoints:
|
||||
|
||||
```typescript
|
||||
import { createLineOfSightSmoother } from '@esengine/pathfinding';
|
||||
|
||||
const smoother = createLineOfSightSmoother();
|
||||
const smoothedPath = smoother.smooth(result.path, grid);
|
||||
```
|
||||
|
||||
### Curve Smoothing
|
||||
|
||||
Catmull-Rom spline:
|
||||
|
||||
```typescript
|
||||
import { createCatmullRomSmoother } from '@esengine/pathfinding';
|
||||
|
||||
const smoother = createCatmullRomSmoother(5, 0.5);
|
||||
const curvedPath = smoother.smooth(result.path, grid);
|
||||
```
|
||||
|
||||
### Combined Smoothing
|
||||
|
||||
```typescript
|
||||
import { createCombinedSmoother } from '@esengine/pathfinding';
|
||||
|
||||
const smoother = createCombinedSmoother(5, 0.5);
|
||||
const finalPath = smoother.smooth(result.path, grid);
|
||||
```
|
||||
|
||||
### Line of Sight Functions
|
||||
|
||||
```typescript
|
||||
import { bresenhamLineOfSight, raycastLineOfSight } from '@esengine/pathfinding';
|
||||
|
||||
const hasLOS = bresenhamLineOfSight(x1, y1, x2, y2, grid);
|
||||
const hasLOS2 = raycastLineOfSight(x1, y1, x2, y2, grid, 0.5);
|
||||
```
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Dynamic Obstacles
|
||||
|
||||
```typescript
|
||||
class DynamicPathfinding {
|
||||
private grid: GridMap;
|
||||
private pathfinder: AStarPathfinder;
|
||||
private dynamicObstacles: Set<string> = new Set();
|
||||
|
||||
addDynamicObstacle(x: number, y: number): void {
|
||||
this.dynamicObstacles.add(`${x},${y}`);
|
||||
this.grid.setWalkable(x, y, false);
|
||||
}
|
||||
|
||||
removeDynamicObstacle(x: number, y: number): void {
|
||||
this.dynamicObstacles.delete(`${x},${y}`);
|
||||
this.grid.setWalkable(x, y, true);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Terrain Costs
|
||||
|
||||
```typescript
|
||||
const grid = createGridMap(50, 50);
|
||||
|
||||
// Normal ground - cost 1 (default)
|
||||
// Sand - cost 2
|
||||
for (let y = 10; y < 20; y++) {
|
||||
for (let x = 0; x < 50; x++) {
|
||||
grid.setCost(x, y, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Swamp - cost 4
|
||||
for (let y = 30; y < 35; y++) {
|
||||
for (let x = 20; x < 30; x++) {
|
||||
grid.setCost(x, y, 4);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Blueprint Nodes
|
||||
|
||||
- `FindPath` - Find path
|
||||
- `FindPathSmooth` - Find and smooth path
|
||||
- `IsWalkable` - Check walkability
|
||||
- `GetPathLength` - Get path point count
|
||||
- `GetPathDistance` - Get total path distance
|
||||
- `GetPathPoint` - Get specific path point
|
||||
- `MoveAlongPath` - Move along path
|
||||
- `HasLineOfSight` - Check line of sight
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Limit search range**: `{ maxNodes: 1000 }`
|
||||
2. **Use heuristic weight**: `{ heuristicWeight: 1.5 }` (faster but may not be optimal)
|
||||
3. **Reuse pathfinder instances**
|
||||
4. **Use NavMesh for complex terrain**
|
||||
5. **Choose appropriate heuristic for movement type**
|
||||
|
||||
## Grid vs NavMesh
|
||||
|
||||
| Feature | GridMap | NavMesh |
|
||||
|---------|---------|---------|
|
||||
| Use Case | Regular tile maps | Complex polygon terrain |
|
||||
| Memory | Higher (width × height) | Lower (polygon count) |
|
||||
| Precision | Grid-aligned | Continuous coordinates |
|
||||
| Dynamic Updates | Easy | Requires rebuild |
|
||||
| Setup Complexity | Simple | More complex |
|
||||
396
docs/en/modules/procgen/index.md
Normal file
396
docs/en/modules/procgen/index.md
Normal file
@@ -0,0 +1,396 @@
|
||||
# Procedural Generation (Procgen)
|
||||
|
||||
`@esengine/procgen` provides core tools for procedural content generation, including noise functions, seeded random numbers, and various random utilities.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/procgen
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Noise Generation
|
||||
|
||||
```typescript
|
||||
import { createPerlinNoise, createFBM } from '@esengine/procgen';
|
||||
|
||||
// Create Perlin noise
|
||||
const perlin = createPerlinNoise(12345); // seed
|
||||
|
||||
// Sample 2D noise
|
||||
const value = perlin.noise2D(x * 0.1, y * 0.1);
|
||||
console.log(value); // [-1, 1]
|
||||
|
||||
// Use FBM for more natural results
|
||||
const fbm = createFBM(perlin, {
|
||||
octaves: 6,
|
||||
persistence: 0.5
|
||||
});
|
||||
|
||||
const height = fbm.noise2D(x * 0.01, y * 0.01);
|
||||
```
|
||||
|
||||
### Seeded Random
|
||||
|
||||
```typescript
|
||||
import { createSeededRandom } from '@esengine/procgen';
|
||||
|
||||
// Create deterministic random generator
|
||||
const rng = createSeededRandom(42);
|
||||
|
||||
// Same seed always produces same sequence
|
||||
console.log(rng.next()); // 0.xxx
|
||||
console.log(rng.nextInt(1, 100)); // 1-100
|
||||
console.log(rng.nextBool(0.3)); // 30% true
|
||||
```
|
||||
|
||||
### Weighted Random
|
||||
|
||||
```typescript
|
||||
import { createWeightedRandom, createSeededRandom } from '@esengine/procgen';
|
||||
|
||||
const rng = createSeededRandom(42);
|
||||
|
||||
const loot = createWeightedRandom([
|
||||
{ value: 'common', weight: 60 },
|
||||
{ value: 'uncommon', weight: 25 },
|
||||
{ value: 'rare', weight: 10 },
|
||||
{ value: 'legendary', weight: 5 }
|
||||
]);
|
||||
|
||||
const drop = loot.pick(rng);
|
||||
console.log(drop); // Likely 'common'
|
||||
```
|
||||
|
||||
## Noise Functions
|
||||
|
||||
### Perlin Noise
|
||||
|
||||
Classic gradient noise, output range [-1, 1]:
|
||||
|
||||
```typescript
|
||||
import { createPerlinNoise } from '@esengine/procgen';
|
||||
|
||||
const perlin = createPerlinNoise(seed);
|
||||
const value2D = perlin.noise2D(x, y);
|
||||
const value3D = perlin.noise3D(x, y, z);
|
||||
```
|
||||
|
||||
### Simplex Noise
|
||||
|
||||
Faster than Perlin, less directional bias:
|
||||
|
||||
```typescript
|
||||
import { createSimplexNoise } from '@esengine/procgen';
|
||||
|
||||
const simplex = createSimplexNoise(seed);
|
||||
const value = simplex.noise2D(x, y);
|
||||
```
|
||||
|
||||
### Worley Noise
|
||||
|
||||
Cell-based noise for stone, cell textures:
|
||||
|
||||
```typescript
|
||||
import { createWorleyNoise } from '@esengine/procgen';
|
||||
|
||||
const worley = createWorleyNoise(seed);
|
||||
const distance = worley.noise2D(x, y);
|
||||
```
|
||||
|
||||
### FBM (Fractal Brownian Motion)
|
||||
|
||||
Layer multiple noise octaves for richer detail:
|
||||
|
||||
```typescript
|
||||
import { createPerlinNoise, createFBM } from '@esengine/procgen';
|
||||
|
||||
const baseNoise = createPerlinNoise(seed);
|
||||
|
||||
const fbm = createFBM(baseNoise, {
|
||||
octaves: 6, // Layer count (more = richer detail)
|
||||
lacunarity: 2.0, // Frequency multiplier
|
||||
persistence: 0.5, // Amplitude decay
|
||||
frequency: 1.0, // Initial frequency
|
||||
amplitude: 1.0 // Initial amplitude
|
||||
});
|
||||
|
||||
// Standard FBM
|
||||
const value = fbm.noise2D(x, y);
|
||||
|
||||
// Ridged FBM (for mountains)
|
||||
const ridged = fbm.ridged2D(x, y);
|
||||
|
||||
// Turbulence
|
||||
const turb = fbm.turbulence2D(x, y);
|
||||
|
||||
// Billowed (for clouds)
|
||||
const cloud = fbm.billowed2D(x, y);
|
||||
```
|
||||
|
||||
## Seeded Random API
|
||||
|
||||
### SeededRandom
|
||||
|
||||
Deterministic PRNG based on xorshift128+:
|
||||
|
||||
```typescript
|
||||
import { createSeededRandom } from '@esengine/procgen';
|
||||
|
||||
const rng = createSeededRandom(42);
|
||||
```
|
||||
|
||||
### Basic Methods
|
||||
|
||||
```typescript
|
||||
rng.next(); // [0, 1) float
|
||||
rng.nextInt(1, 10); // [min, max] integer
|
||||
rng.nextFloat(0, 100); // [min, max) float
|
||||
rng.nextBool(); // 50%
|
||||
rng.nextBool(0.3); // 30%
|
||||
rng.reset(); // Reset to initial state
|
||||
```
|
||||
|
||||
### Distribution Methods
|
||||
|
||||
```typescript
|
||||
// Normal distribution (Gaussian)
|
||||
rng.nextGaussian(); // mean 0, stdDev 1
|
||||
rng.nextGaussian(100, 15); // mean 100, stdDev 15
|
||||
|
||||
// Exponential distribution
|
||||
rng.nextExponential(); // λ = 1
|
||||
rng.nextExponential(0.5); // λ = 0.5
|
||||
```
|
||||
|
||||
### Geometry Methods
|
||||
|
||||
```typescript
|
||||
// Uniform point in circle
|
||||
const point = rng.nextPointInCircle(50); // { x, y }
|
||||
|
||||
// Point on circle edge
|
||||
const edge = rng.nextPointOnCircle(50); // { x, y }
|
||||
|
||||
// Uniform point in sphere
|
||||
const point3D = rng.nextPointInSphere(50); // { x, y, z }
|
||||
|
||||
// Random direction vector
|
||||
const dir = rng.nextDirection2D(); // { x, y }, length 1
|
||||
```
|
||||
|
||||
## Weighted Random API
|
||||
|
||||
### WeightedRandom
|
||||
|
||||
Precomputed cumulative weights for efficient selection:
|
||||
|
||||
```typescript
|
||||
import { createWeightedRandom } from '@esengine/procgen';
|
||||
|
||||
const selector = createWeightedRandom([
|
||||
{ value: 'apple', weight: 5 },
|
||||
{ value: 'banana', weight: 3 },
|
||||
{ value: 'cherry', weight: 2 }
|
||||
]);
|
||||
|
||||
const result = selector.pick(rng);
|
||||
const result2 = selector.pickRandom(); // Uses Math.random
|
||||
|
||||
console.log(selector.getProbability(0)); // 0.5 (5/10)
|
||||
console.log(selector.size); // 3
|
||||
console.log(selector.totalWeight); // 10
|
||||
```
|
||||
|
||||
### Convenience Functions
|
||||
|
||||
```typescript
|
||||
import { weightedPick, weightedPickFromMap } from '@esengine/procgen';
|
||||
|
||||
const item = weightedPick([
|
||||
{ value: 'a', weight: 1 },
|
||||
{ value: 'b', weight: 2 }
|
||||
], rng);
|
||||
|
||||
const item2 = weightedPickFromMap({
|
||||
'common': 60,
|
||||
'rare': 30,
|
||||
'epic': 10
|
||||
}, rng);
|
||||
```
|
||||
|
||||
## Shuffle and Sampling
|
||||
|
||||
### shuffle / shuffleCopy
|
||||
|
||||
Fisher-Yates shuffle:
|
||||
|
||||
```typescript
|
||||
import { shuffle, shuffleCopy } from '@esengine/procgen';
|
||||
|
||||
const arr = [1, 2, 3, 4, 5];
|
||||
shuffle(arr, rng); // In-place
|
||||
const shuffled = shuffleCopy(arr, rng); // Copy
|
||||
```
|
||||
|
||||
### pickOne
|
||||
|
||||
```typescript
|
||||
import { pickOne } from '@esengine/procgen';
|
||||
|
||||
const item = pickOne(['a', 'b', 'c', 'd'], rng);
|
||||
```
|
||||
|
||||
### sample / sampleWithReplacement
|
||||
|
||||
```typescript
|
||||
import { sample, sampleWithReplacement } from '@esengine/procgen';
|
||||
|
||||
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const unique = sample(arr, 3, rng); // 3 unique
|
||||
const withRep = sampleWithReplacement(arr, 5, rng); // 5 with replacement
|
||||
```
|
||||
|
||||
### randomIntegers
|
||||
|
||||
```typescript
|
||||
import { randomIntegers } from '@esengine/procgen';
|
||||
|
||||
// 5 unique random integers from 1-100
|
||||
const nums = randomIntegers(1, 100, 5, rng);
|
||||
```
|
||||
|
||||
### weightedSample
|
||||
|
||||
```typescript
|
||||
import { weightedSample } from '@esengine/procgen';
|
||||
|
||||
const items = ['A', 'B', 'C', 'D', 'E'];
|
||||
const weights = [10, 8, 6, 4, 2];
|
||||
const selected = weightedSample(items, weights, 3, rng);
|
||||
```
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Procedural Terrain
|
||||
|
||||
```typescript
|
||||
import { createPerlinNoise, createFBM } from '@esengine/procgen';
|
||||
|
||||
class TerrainGenerator {
|
||||
private fbm: FBM;
|
||||
private moistureFbm: FBM;
|
||||
|
||||
constructor(seed: number) {
|
||||
const heightNoise = createPerlinNoise(seed);
|
||||
const moistureNoise = createPerlinNoise(seed + 1000);
|
||||
|
||||
this.fbm = createFBM(heightNoise, {
|
||||
octaves: 8,
|
||||
persistence: 0.5,
|
||||
frequency: 0.01
|
||||
});
|
||||
|
||||
this.moistureFbm = createFBM(moistureNoise, {
|
||||
octaves: 4,
|
||||
persistence: 0.6,
|
||||
frequency: 0.02
|
||||
});
|
||||
}
|
||||
|
||||
getHeight(x: number, y: number): number {
|
||||
let height = this.fbm.noise2D(x, y);
|
||||
height += this.fbm.ridged2D(x * 0.5, y * 0.5) * 0.3;
|
||||
return (height + 1) * 0.5; // Normalize to [0, 1]
|
||||
}
|
||||
|
||||
getBiome(x: number, y: number): string {
|
||||
const height = this.getHeight(x, y);
|
||||
const moisture = (this.moistureFbm.noise2D(x, y) + 1) * 0.5;
|
||||
|
||||
if (height < 0.3) return 'water';
|
||||
if (height < 0.4) return 'beach';
|
||||
if (height > 0.8) return 'mountain';
|
||||
|
||||
if (moisture < 0.3) return 'desert';
|
||||
if (moisture > 0.7) return 'forest';
|
||||
return 'grassland';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Loot System
|
||||
|
||||
```typescript
|
||||
import { createSeededRandom, createWeightedRandom } from '@esengine/procgen';
|
||||
|
||||
class LootSystem {
|
||||
private rng: SeededRandom;
|
||||
private raritySelector: WeightedRandom<string>;
|
||||
|
||||
constructor(seed: number) {
|
||||
this.rng = createSeededRandom(seed);
|
||||
this.raritySelector = createWeightedRandom([
|
||||
{ value: 'common', weight: 60 },
|
||||
{ value: 'uncommon', weight: 25 },
|
||||
{ value: 'rare', weight: 10 },
|
||||
{ value: 'legendary', weight: 5 }
|
||||
]);
|
||||
}
|
||||
|
||||
generateLoot(count: number): LootItem[] {
|
||||
const loot: LootItem[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const rarity = this.raritySelector.pick(this.rng);
|
||||
// Get item from rarity table...
|
||||
loot.push(item);
|
||||
}
|
||||
return loot;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Blueprint Nodes
|
||||
|
||||
### Noise Nodes
|
||||
- `SampleNoise2D` - Sample 2D noise
|
||||
- `SampleFBM` - Sample FBM noise
|
||||
|
||||
### Random Nodes
|
||||
- `SeededRandom` - Generate random float
|
||||
- `SeededRandomInt` - Generate random integer
|
||||
- `WeightedPick` - Weighted random selection
|
||||
- `ShuffleArray` - Shuffle array
|
||||
- `PickRandom` - Pick random element
|
||||
- `SampleArray` - Sample from array
|
||||
- `RandomPointInCircle` - Random point in circle
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use seeds for reproducibility**
|
||||
```typescript
|
||||
const seed = Date.now();
|
||||
const rng = createSeededRandom(seed);
|
||||
saveSeed(seed);
|
||||
```
|
||||
|
||||
2. **Precompute weighted selectors**
|
||||
```typescript
|
||||
// Good: Create once, use many times
|
||||
const selector = createWeightedRandom(items);
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
selector.pick(rng);
|
||||
}
|
||||
```
|
||||
|
||||
3. **Choose appropriate noise**
|
||||
- Perlin: Smooth terrain, clouds
|
||||
- Simplex: Performance-critical
|
||||
- Worley: Cell textures, stone
|
||||
- FBM: Natural multi-detail effects
|
||||
|
||||
4. **Tune FBM parameters**
|
||||
- `octaves`: More = richer detail, higher cost
|
||||
- `persistence`: 0.5 is common, higher = more high-frequency detail
|
||||
- `lacunarity`: Usually 2, controls frequency growth
|
||||
322
docs/en/modules/spatial/index.md
Normal file
322
docs/en/modules/spatial/index.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# Spatial Index System
|
||||
|
||||
`@esengine/spatial` provides efficient spatial querying and indexing, including range queries, nearest neighbor queries, raycasting, and AOI (Area of Interest) management.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/spatial
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Spatial Index
|
||||
|
||||
```typescript
|
||||
import { createGridSpatialIndex } from '@esengine/spatial';
|
||||
|
||||
// Create spatial index (cell size 100)
|
||||
const spatialIndex = createGridSpatialIndex<Entity>(100);
|
||||
|
||||
// Insert objects
|
||||
spatialIndex.insert(player, { x: 100, y: 200 });
|
||||
spatialIndex.insert(enemy1, { x: 150, y: 250 });
|
||||
spatialIndex.insert(enemy2, { x: 500, y: 600 });
|
||||
|
||||
// Find objects within radius
|
||||
const nearby = spatialIndex.findInRadius({ x: 100, y: 200 }, 100);
|
||||
console.log(nearby); // [player, enemy1]
|
||||
|
||||
// Find nearest object
|
||||
const nearest = spatialIndex.findNearest({ x: 100, y: 200 });
|
||||
console.log(nearest); // enemy1
|
||||
|
||||
// Update position
|
||||
spatialIndex.update(player, { x: 120, y: 220 });
|
||||
```
|
||||
|
||||
### AOI (Area of Interest)
|
||||
|
||||
```typescript
|
||||
import { createGridAOI } from '@esengine/spatial';
|
||||
|
||||
// Create AOI manager
|
||||
const aoi = createGridAOI<Entity>(100);
|
||||
|
||||
// Add observers
|
||||
aoi.addObserver(player, { x: 100, y: 100 }, { viewRange: 200 });
|
||||
aoi.addObserver(npc, { x: 150, y: 150 }, { viewRange: 150 });
|
||||
|
||||
// Listen to enter/exit events
|
||||
aoi.addListener((event) => {
|
||||
if (event.type === 'enter') {
|
||||
console.log(`${event.observer} saw ${event.target}`);
|
||||
} else if (event.type === 'exit') {
|
||||
console.log(`${event.target} left ${event.observer}'s view`);
|
||||
}
|
||||
});
|
||||
|
||||
// Update position (triggers enter/exit events)
|
||||
aoi.updatePosition(player, { x: 200, y: 200 });
|
||||
|
||||
// Get visible entities
|
||||
const visible = aoi.getEntitiesInView(player);
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Spatial Index vs AOI
|
||||
|
||||
| Feature | SpatialIndex | AOI |
|
||||
|---------|--------------|-----|
|
||||
| Purpose | General spatial queries | Entity visibility tracking |
|
||||
| Events | No event notification | Enter/exit events |
|
||||
| Direction | One-way query | Two-way tracking |
|
||||
| Use Cases | Collision, range attacks | MMO sync, NPC AI perception |
|
||||
|
||||
### IBounds
|
||||
|
||||
```typescript
|
||||
interface IBounds {
|
||||
readonly minX: number;
|
||||
readonly minY: number;
|
||||
readonly maxX: number;
|
||||
readonly maxY: number;
|
||||
}
|
||||
```
|
||||
|
||||
### IRaycastHit
|
||||
|
||||
```typescript
|
||||
interface IRaycastHit<T> {
|
||||
readonly target: T; // Hit object
|
||||
readonly point: IVector2; // Hit point
|
||||
readonly normal: IVector2;// Hit normal
|
||||
readonly distance: number;// Distance from origin
|
||||
}
|
||||
```
|
||||
|
||||
## Spatial Index API
|
||||
|
||||
### createGridSpatialIndex
|
||||
|
||||
```typescript
|
||||
function createGridSpatialIndex<T>(cellSize?: number): GridSpatialIndex<T>
|
||||
```
|
||||
|
||||
**Choosing cellSize:**
|
||||
- Too small: High memory, reduced query efficiency
|
||||
- Too large: Many objects per cell, slow iteration
|
||||
- Recommended: 1-2x average object spacing
|
||||
|
||||
### Management Methods
|
||||
|
||||
```typescript
|
||||
spatialIndex.insert(entity, position);
|
||||
spatialIndex.remove(entity);
|
||||
spatialIndex.update(entity, newPosition);
|
||||
spatialIndex.clear();
|
||||
```
|
||||
|
||||
### Query Methods
|
||||
|
||||
#### findInRadius
|
||||
|
||||
```typescript
|
||||
const enemies = spatialIndex.findInRadius(
|
||||
{ x: 100, y: 200 },
|
||||
50,
|
||||
(entity) => entity.type === 'enemy' // Optional filter
|
||||
);
|
||||
```
|
||||
|
||||
#### findInRect
|
||||
|
||||
```typescript
|
||||
import { createBounds } from '@esengine/spatial';
|
||||
|
||||
const bounds = createBounds(0, 0, 200, 200);
|
||||
const entities = spatialIndex.findInRect(bounds);
|
||||
```
|
||||
|
||||
#### findNearest
|
||||
|
||||
```typescript
|
||||
const nearest = spatialIndex.findNearest(
|
||||
playerPosition,
|
||||
500, // maxDistance
|
||||
(entity) => entity.type === 'enemy'
|
||||
);
|
||||
```
|
||||
|
||||
#### findKNearest
|
||||
|
||||
```typescript
|
||||
const nearestEnemies = spatialIndex.findKNearest(
|
||||
playerPosition,
|
||||
5, // k
|
||||
500, // maxDistance
|
||||
(entity) => entity.type === 'enemy'
|
||||
);
|
||||
```
|
||||
|
||||
#### raycast / raycastFirst
|
||||
|
||||
```typescript
|
||||
const hits = spatialIndex.raycast(origin, direction, maxDistance);
|
||||
const firstHit = spatialIndex.raycastFirst(origin, direction, maxDistance);
|
||||
```
|
||||
|
||||
## AOI API
|
||||
|
||||
### createGridAOI
|
||||
|
||||
```typescript
|
||||
function createGridAOI<T>(cellSize?: number): GridAOI<T>
|
||||
```
|
||||
|
||||
### Observer Management
|
||||
|
||||
```typescript
|
||||
// Add observer
|
||||
aoi.addObserver(player, position, {
|
||||
viewRange: 200,
|
||||
observable: true // Can be seen by others
|
||||
});
|
||||
|
||||
// Remove observer
|
||||
aoi.removeObserver(player);
|
||||
|
||||
// Update position
|
||||
aoi.updatePosition(player, newPosition);
|
||||
|
||||
// Update view range
|
||||
aoi.updateViewRange(player, 300);
|
||||
```
|
||||
|
||||
### Query Methods
|
||||
|
||||
```typescript
|
||||
// Get entities in observer's view
|
||||
const visible = aoi.getEntitiesInView(player);
|
||||
|
||||
// Get observers who can see entity
|
||||
const observers = aoi.getObserversOf(monster);
|
||||
|
||||
// Check visibility
|
||||
if (aoi.canSee(player, enemy)) { ... }
|
||||
```
|
||||
|
||||
### Event System
|
||||
|
||||
```typescript
|
||||
// Global event listener
|
||||
aoi.addListener((event) => {
|
||||
switch (event.type) {
|
||||
case 'enter': /* entered view */ break;
|
||||
case 'exit': /* left view */ break;
|
||||
}
|
||||
});
|
||||
|
||||
// Entity-specific listener
|
||||
aoi.addEntityListener(player, (event) => {
|
||||
if (event.type === 'enter') {
|
||||
sendToClient(player, 'entity_enter', event.target);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Utility Functions
|
||||
|
||||
### Bounds Creation
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createBounds,
|
||||
createBoundsFromCenter,
|
||||
createBoundsFromCircle
|
||||
} from '@esengine/spatial';
|
||||
|
||||
const bounds1 = createBounds(0, 0, 100, 100);
|
||||
const bounds2 = createBoundsFromCenter({ x: 50, y: 50 }, 100, 100);
|
||||
const bounds3 = createBoundsFromCircle({ x: 50, y: 50 }, 50);
|
||||
```
|
||||
|
||||
### Geometry Checks
|
||||
|
||||
```typescript
|
||||
import {
|
||||
isPointInBounds,
|
||||
boundsIntersect,
|
||||
boundsIntersectsCircle,
|
||||
distance,
|
||||
distanceSquared
|
||||
} from '@esengine/spatial';
|
||||
|
||||
if (isPointInBounds(point, bounds)) { ... }
|
||||
if (boundsIntersect(boundsA, boundsB)) { ... }
|
||||
if (boundsIntersectsCircle(bounds, center, radius)) { ... }
|
||||
const dist = distance(pointA, pointB);
|
||||
const distSq = distanceSquared(pointA, pointB); // Faster
|
||||
```
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Range Attack Detection
|
||||
|
||||
```typescript
|
||||
class CombatSystem {
|
||||
private spatialIndex: ISpatialIndex<Entity>;
|
||||
|
||||
dealAreaDamage(center: IVector2, radius: number, damage: number): void {
|
||||
const targets = this.spatialIndex.findInRadius(
|
||||
center, radius,
|
||||
(entity) => entity.hasComponent(HealthComponent)
|
||||
);
|
||||
|
||||
for (const target of targets) {
|
||||
target.getComponent(HealthComponent).takeDamage(damage);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MMO Sync System
|
||||
|
||||
```typescript
|
||||
class SyncSystem {
|
||||
private aoi: IAOIManager<Player>;
|
||||
|
||||
constructor() {
|
||||
this.aoi = createGridAOI<Player>(100);
|
||||
|
||||
this.aoi.addListener((event) => {
|
||||
const packet = this.createSyncPacket(event);
|
||||
this.sendToPlayer(event.observer, packet);
|
||||
});
|
||||
}
|
||||
|
||||
onPlayerMove(player: Player, newPosition: IVector2): void {
|
||||
this.aoi.updatePosition(player, newPosition);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Blueprint Nodes
|
||||
|
||||
### Spatial Query Nodes
|
||||
- `FindInRadius`, `FindInRect`, `FindNearest`, `FindKNearest`
|
||||
- `Raycast`, `RaycastFirst`
|
||||
|
||||
### AOI Nodes
|
||||
- `GetEntitiesInView`, `GetObserversOf`, `CanSee`
|
||||
- `OnEntityEnterView`, `OnEntityExitView`
|
||||
|
||||
## Service Tokens
|
||||
|
||||
```typescript
|
||||
import { SpatialIndexToken, AOIManagerToken } from '@esengine/spatial';
|
||||
|
||||
services.register(SpatialIndexToken, createGridSpatialIndex(100));
|
||||
services.register(AOIManagerToken, createGridAOI(100));
|
||||
```
|
||||
352
docs/en/modules/timer/index.md
Normal file
352
docs/en/modules/timer/index.md
Normal file
@@ -0,0 +1,352 @@
|
||||
# Timer System
|
||||
|
||||
`@esengine/timer` provides a flexible timer and cooldown system for delayed execution, repeating tasks, skill cooldowns, and more.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/timer
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { createTimerService } from '@esengine/timer';
|
||||
|
||||
// Create timer service
|
||||
const timerService = createTimerService();
|
||||
|
||||
// One-time timer (executes after 1 second)
|
||||
const handle = timerService.schedule('myTimer', 1000, () => {
|
||||
console.log('Timer fired!');
|
||||
});
|
||||
|
||||
// Repeating timer (every 100ms)
|
||||
timerService.scheduleRepeating('heartbeat', 100, () => {
|
||||
console.log('Tick');
|
||||
});
|
||||
|
||||
// Cooldown system (5 second cooldown)
|
||||
timerService.startCooldown('skill_fireball', 5000);
|
||||
|
||||
if (timerService.isCooldownReady('skill_fireball')) {
|
||||
useFireball();
|
||||
timerService.startCooldown('skill_fireball', 5000);
|
||||
}
|
||||
|
||||
// Update in game loop
|
||||
function gameLoop(deltaTime: number) {
|
||||
timerService.update(deltaTime);
|
||||
}
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Timer vs Cooldown
|
||||
|
||||
| Feature | Timer | Cooldown |
|
||||
|---------|-------|----------|
|
||||
| Purpose | Delayed code execution | Rate limiting |
|
||||
| Callback | Has callback function | No callback |
|
||||
| Repeat | Supports repeating | One-time |
|
||||
| Query | Query remaining time | Query progress/ready status |
|
||||
|
||||
### TimerHandle
|
||||
|
||||
Handle object returned when scheduling a timer:
|
||||
|
||||
```typescript
|
||||
interface TimerHandle {
|
||||
readonly id: string; // Timer ID
|
||||
readonly isValid: boolean; // Whether valid (not cancelled)
|
||||
cancel(): void; // Cancel timer
|
||||
}
|
||||
```
|
||||
|
||||
### TimerInfo
|
||||
|
||||
Timer information object:
|
||||
|
||||
```typescript
|
||||
interface TimerInfo {
|
||||
readonly id: string; // Timer ID
|
||||
readonly remaining: number; // Remaining time (ms)
|
||||
readonly repeating: boolean; // Whether repeating
|
||||
readonly interval?: number; // Interval (repeating only)
|
||||
}
|
||||
```
|
||||
|
||||
### CooldownInfo
|
||||
|
||||
Cooldown information object:
|
||||
|
||||
```typescript
|
||||
interface CooldownInfo {
|
||||
readonly id: string; // Cooldown ID
|
||||
readonly duration: number; // Total duration (ms)
|
||||
readonly remaining: number; // Remaining time (ms)
|
||||
readonly progress: number; // Progress (0-1, 0=started, 1=finished)
|
||||
readonly isReady: boolean; // Whether ready
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### createTimerService
|
||||
|
||||
```typescript
|
||||
function createTimerService(config?: TimerServiceConfig): ITimerService
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `maxTimers` | `number` | `0` | Maximum timer count (0 = unlimited) |
|
||||
| `maxCooldowns` | `number` | `0` | Maximum cooldown count (0 = unlimited) |
|
||||
|
||||
### Timer API
|
||||
|
||||
#### schedule
|
||||
|
||||
Schedule a one-time timer:
|
||||
|
||||
```typescript
|
||||
const handle = timerService.schedule('explosion', 2000, () => {
|
||||
createExplosion();
|
||||
});
|
||||
|
||||
// Cancel early
|
||||
handle.cancel();
|
||||
```
|
||||
|
||||
#### scheduleRepeating
|
||||
|
||||
Schedule a repeating timer:
|
||||
|
||||
```typescript
|
||||
// Execute every second
|
||||
timerService.scheduleRepeating('regen', 1000, () => {
|
||||
player.hp += 5;
|
||||
});
|
||||
|
||||
// Execute immediately once, then repeat every second
|
||||
timerService.scheduleRepeating('tick', 1000, () => {
|
||||
console.log('Tick');
|
||||
}, true); // immediate = true
|
||||
```
|
||||
|
||||
#### cancel / cancelById
|
||||
|
||||
Cancel timers:
|
||||
|
||||
```typescript
|
||||
// Cancel by handle
|
||||
handle.cancel();
|
||||
// or
|
||||
timerService.cancel(handle);
|
||||
|
||||
// Cancel by ID
|
||||
timerService.cancelById('regen');
|
||||
```
|
||||
|
||||
#### hasTimer
|
||||
|
||||
Check if timer exists:
|
||||
|
||||
```typescript
|
||||
if (timerService.hasTimer('explosion')) {
|
||||
console.log('Explosion is pending');
|
||||
}
|
||||
```
|
||||
|
||||
#### getTimerInfo
|
||||
|
||||
Get timer information:
|
||||
|
||||
```typescript
|
||||
const info = timerService.getTimerInfo('explosion');
|
||||
if (info) {
|
||||
console.log(`Remaining: ${info.remaining}ms`);
|
||||
console.log(`Repeating: ${info.repeating}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Cooldown API
|
||||
|
||||
#### startCooldown
|
||||
|
||||
Start a cooldown:
|
||||
|
||||
```typescript
|
||||
timerService.startCooldown('skill_fireball', 5000);
|
||||
```
|
||||
|
||||
#### isCooldownReady / isOnCooldown
|
||||
|
||||
Check cooldown status:
|
||||
|
||||
```typescript
|
||||
if (timerService.isCooldownReady('skill_fireball')) {
|
||||
castFireball();
|
||||
timerService.startCooldown('skill_fireball', 5000);
|
||||
}
|
||||
|
||||
if (timerService.isOnCooldown('skill_fireball')) {
|
||||
console.log('On cooldown...');
|
||||
}
|
||||
```
|
||||
|
||||
#### getCooldownProgress / getCooldownRemaining
|
||||
|
||||
Get cooldown progress:
|
||||
|
||||
```typescript
|
||||
// Progress 0-1 (0=started, 1=complete)
|
||||
const progress = timerService.getCooldownProgress('skill_fireball');
|
||||
console.log(`Progress: ${(progress * 100).toFixed(0)}%`);
|
||||
|
||||
// Remaining time (ms)
|
||||
const remaining = timerService.getCooldownRemaining('skill_fireball');
|
||||
console.log(`Remaining: ${(remaining / 1000).toFixed(1)}s`);
|
||||
```
|
||||
|
||||
#### getCooldownInfo
|
||||
|
||||
Get complete cooldown info:
|
||||
|
||||
```typescript
|
||||
const info = timerService.getCooldownInfo('skill_fireball');
|
||||
if (info) {
|
||||
console.log(`Duration: ${info.duration}ms`);
|
||||
console.log(`Remaining: ${info.remaining}ms`);
|
||||
console.log(`Progress: ${info.progress}`);
|
||||
console.log(`Ready: ${info.isReady}`);
|
||||
}
|
||||
```
|
||||
|
||||
#### resetCooldown / clearAllCooldowns
|
||||
|
||||
Reset cooldowns:
|
||||
|
||||
```typescript
|
||||
// Reset single cooldown
|
||||
timerService.resetCooldown('skill_fireball');
|
||||
|
||||
// Clear all cooldowns (e.g., on respawn)
|
||||
timerService.clearAllCooldowns();
|
||||
```
|
||||
|
||||
### Lifecycle
|
||||
|
||||
#### update
|
||||
|
||||
Update timer service (call every frame):
|
||||
|
||||
```typescript
|
||||
function gameLoop(deltaTime: number) {
|
||||
timerService.update(deltaTime); // deltaTime in ms
|
||||
}
|
||||
```
|
||||
|
||||
#### clear
|
||||
|
||||
Clear all timers and cooldowns:
|
||||
|
||||
```typescript
|
||||
timerService.clear();
|
||||
```
|
||||
|
||||
### Debug Properties
|
||||
|
||||
```typescript
|
||||
console.log(timerService.activeTimerCount);
|
||||
console.log(timerService.activeCooldownCount);
|
||||
const timerIds = timerService.getActiveTimerIds();
|
||||
const cooldownIds = timerService.getActiveCooldownIds();
|
||||
```
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Skill Cooldown System
|
||||
|
||||
```typescript
|
||||
import { createTimerService, type ITimerService } from '@esengine/timer';
|
||||
|
||||
class SkillSystem {
|
||||
private timerService: ITimerService;
|
||||
private skills: Map<string, SkillData> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.timerService = createTimerService();
|
||||
}
|
||||
|
||||
useSkill(skillId: string): boolean {
|
||||
const skill = this.skills.get(skillId);
|
||||
if (!skill) return false;
|
||||
|
||||
if (!this.timerService.isCooldownReady(skillId)) {
|
||||
const remaining = this.timerService.getCooldownRemaining(skillId);
|
||||
console.log(`Skill ${skillId} on cooldown, ${remaining}ms remaining`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.executeSkill(skill);
|
||||
this.timerService.startCooldown(skillId, skill.cooldown);
|
||||
return true;
|
||||
}
|
||||
|
||||
update(dt: number): void {
|
||||
this.timerService.update(dt);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### DOT Effects
|
||||
|
||||
```typescript
|
||||
class EffectSystem {
|
||||
private timerService: ITimerService;
|
||||
|
||||
applyDOT(target: Entity, damage: number, duration: number): void {
|
||||
const dotId = `dot_${target.id}_${Date.now()}`;
|
||||
let elapsed = 0;
|
||||
|
||||
this.timerService.scheduleRepeating(dotId, 1000, () => {
|
||||
elapsed += 1000;
|
||||
target.takeDamage(damage);
|
||||
|
||||
if (elapsed >= duration) {
|
||||
this.timerService.cancelById(dotId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Blueprint Nodes
|
||||
|
||||
### Cooldown Nodes
|
||||
|
||||
- `StartCooldown` - Start cooldown
|
||||
- `IsCooldownReady` - Check if cooldown is ready
|
||||
- `GetCooldownProgress` - Get cooldown progress
|
||||
- `GetCooldownInfo` - Get cooldown info
|
||||
- `ResetCooldown` - Reset cooldown
|
||||
|
||||
### Timer Nodes
|
||||
|
||||
- `HasTimer` - Check if timer exists
|
||||
- `CancelTimer` - Cancel timer
|
||||
- `GetTimerRemaining` - Get timer remaining time
|
||||
|
||||
## Service Token
|
||||
|
||||
For dependency injection:
|
||||
|
||||
```typescript
|
||||
import { TimerServiceToken, createTimerService } from '@esengine/timer';
|
||||
|
||||
services.register(TimerServiceToken, createTimerService());
|
||||
const timerService = services.get(TimerServiceToken);
|
||||
```
|
||||
@@ -4,7 +4,24 @@
|
||||
|
||||
## 安装
|
||||
|
||||
### NPM 安装
|
||||
### 使用 CLI(推荐)
|
||||
|
||||
在现有项目中添加 ECS 的最简单方式:
|
||||
|
||||
```bash
|
||||
# 在项目目录中运行
|
||||
npx @esengine/cli init
|
||||
```
|
||||
|
||||
CLI 会自动检测项目类型(Cocos Creator 2.x/3.x、LayaAir 3.x 或 Node.js)并生成相应的集成代码,包括:
|
||||
|
||||
- `ECSManager` 组件/脚本 - 负责 ECS 生命周期管理
|
||||
- 示例组件和系统 - 帮助快速上手
|
||||
- 自动安装依赖
|
||||
|
||||
### NPM 手动安装
|
||||
|
||||
如果你更喜欢手动配置,可以直接安装:
|
||||
|
||||
```bash
|
||||
# 使用 npm
|
||||
@@ -333,27 +350,39 @@ function gameLoop(deltaTime: number) {
|
||||
|
||||
## 与游戏引擎集成
|
||||
|
||||
### Laya 引擎集成
|
||||
### Laya 3.x 引擎集成
|
||||
|
||||
推荐使用 `Laya.Script` 组件来管理 ECS 生命周期:
|
||||
|
||||
```typescript
|
||||
import { Stage } from "laya/display/Stage";
|
||||
import { Laya } from "Laya";
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { Core, Scene } from '@esengine/ecs-framework';
|
||||
|
||||
// 初始化 Laya
|
||||
Laya.init(800, 600).then(() => {
|
||||
// 初始化 ECS
|
||||
Core.create(true);
|
||||
Core.setScene(new GameScene());
|
||||
const { regClass } = Laya;
|
||||
|
||||
// 启动游戏循环
|
||||
Laya.timer.frameLoop(1, this, () => {
|
||||
const deltaTime = Laya.timer.delta / 1000;
|
||||
Core.update(deltaTime); // 自动更新全局服务和场景
|
||||
});
|
||||
});
|
||||
@regClass()
|
||||
export class ECSManager extends Laya.Script {
|
||||
private ecsScene = new GameScene();
|
||||
|
||||
onAwake(): void {
|
||||
// 初始化 ECS
|
||||
Core.create({ debug: true });
|
||||
Core.setScene(this.ecsScene);
|
||||
}
|
||||
|
||||
onUpdate(): void {
|
||||
// 自动更新全局服务和场景
|
||||
Core.update(Laya.timer.delta / 1000);
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
// 清理资源
|
||||
Core.destroy();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 Laya IDE 中,将 `ECSManager` 脚本挂载到场景中的节点上即可。
|
||||
|
||||
### Cocos Creator 集成
|
||||
|
||||
```typescript
|
||||
|
||||
507
docs/modules/blueprint/index.md
Normal file
507
docs/modules/blueprint/index.md
Normal file
@@ -0,0 +1,507 @@
|
||||
# 蓝图可视化脚本 (Blueprint)
|
||||
|
||||
`@esengine/blueprint` 提供了一个功能完整的可视化脚本系统,支持节点式编程、事件驱动和蓝图组合。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install @esengine/blueprint
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createBlueprintSystem,
|
||||
createBlueprintComponentData,
|
||||
NodeRegistry,
|
||||
RegisterNode
|
||||
} from '@esengine/blueprint';
|
||||
|
||||
// 创建蓝图系统
|
||||
const blueprintSystem = createBlueprintSystem(scene);
|
||||
|
||||
// 加载蓝图资产
|
||||
const blueprint = await loadBlueprintAsset('player.bp');
|
||||
|
||||
// 创建蓝图组件数据
|
||||
const componentData = createBlueprintComponentData();
|
||||
componentData.blueprintAsset = blueprint;
|
||||
|
||||
// 在游戏循环中更新
|
||||
function gameLoop(dt: number) {
|
||||
blueprintSystem.process(entities, dt);
|
||||
}
|
||||
```
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 蓝图资产结构
|
||||
|
||||
蓝图保存为 `.bp` 文件,包含以下结构:
|
||||
|
||||
```typescript
|
||||
interface BlueprintAsset {
|
||||
version: number; // 格式版本
|
||||
type: 'blueprint'; // 资产类型
|
||||
metadata: BlueprintMetadata; // 元数据
|
||||
variables: BlueprintVariable[]; // 变量定义
|
||||
nodes: BlueprintNode[]; // 节点实例
|
||||
connections: BlueprintConnection[]; // 连接
|
||||
}
|
||||
```
|
||||
|
||||
### 节点类型
|
||||
|
||||
节点按功能分为以下类别:
|
||||
|
||||
| 类别 | 说明 | 颜色 |
|
||||
|------|------|------|
|
||||
| `event` | 事件节点(入口点) | 红色 |
|
||||
| `flow` | 流程控制 | 灰色 |
|
||||
| `entity` | 实体操作 | 蓝色 |
|
||||
| `component` | 组件访问 | 青色 |
|
||||
| `math` | 数学运算 | 绿色 |
|
||||
| `logic` | 逻辑运算 | 红色 |
|
||||
| `variable` | 变量访问 | 紫色 |
|
||||
| `time` | 时间工具 | 青色 |
|
||||
| `debug` | 调试工具 | 灰色 |
|
||||
|
||||
### 引脚类型
|
||||
|
||||
节点通过引脚连接:
|
||||
|
||||
```typescript
|
||||
interface BlueprintPinDefinition {
|
||||
name: string; // 引脚名称
|
||||
type: PinDataType; // 数据类型
|
||||
direction: 'input' | 'output';
|
||||
isExec?: boolean; // 是否是执行引脚
|
||||
defaultValue?: unknown;
|
||||
}
|
||||
|
||||
// 支持的数据类型
|
||||
type PinDataType =
|
||||
| 'exec' // 执行流
|
||||
| 'boolean' // 布尔值
|
||||
| 'number' // 数字
|
||||
| 'string' // 字符串
|
||||
| 'vector2' // 2D 向量
|
||||
| 'vector3' // 3D 向量
|
||||
| 'entity' // 实体引用
|
||||
| 'component' // 组件引用
|
||||
| 'any'; // 任意类型
|
||||
```
|
||||
|
||||
### 变量作用域
|
||||
|
||||
```typescript
|
||||
type VariableScope =
|
||||
| 'local' // 每次执行独立
|
||||
| 'instance' // 每个实体独立
|
||||
| 'global'; // 全局共享
|
||||
```
|
||||
|
||||
## 虚拟机 API
|
||||
|
||||
### BlueprintVM
|
||||
|
||||
蓝图虚拟机负责执行蓝图图:
|
||||
|
||||
```typescript
|
||||
import { BlueprintVM } from '@esengine/blueprint';
|
||||
|
||||
// 创建 VM
|
||||
const vm = new BlueprintVM(blueprintAsset, entity, scene);
|
||||
|
||||
// 启动(触发 BeginPlay)
|
||||
vm.start();
|
||||
|
||||
// 每帧更新(触发 Tick)
|
||||
vm.tick(deltaTime);
|
||||
|
||||
// 停止(触发 EndPlay)
|
||||
vm.stop();
|
||||
|
||||
// 暂停/恢复
|
||||
vm.pause();
|
||||
vm.resume();
|
||||
|
||||
// 触发事件
|
||||
vm.triggerEvent('EventCollision', { other: otherEntity });
|
||||
vm.triggerCustomEvent('OnDamage', { amount: 50 });
|
||||
|
||||
// 调试模式
|
||||
vm.debug = true;
|
||||
```
|
||||
|
||||
### 执行上下文
|
||||
|
||||
```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;
|
||||
}
|
||||
```
|
||||
|
||||
### 执行结果
|
||||
|
||||
```typescript
|
||||
interface ExecutionResult {
|
||||
outputs?: Record<string, unknown>; // 输出值
|
||||
nextExec?: string | null; // 下一个执行引脚
|
||||
delay?: number; // 延迟执行(毫秒)
|
||||
yield?: boolean; // 暂停到下一帧
|
||||
error?: string; // 错误信息
|
||||
}
|
||||
```
|
||||
|
||||
## 自定义节点
|
||||
|
||||
### 定义节点模板
|
||||
|
||||
```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' }
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
### 实现节点执行器
|
||||
|
||||
```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' // 继续执行
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 使用装饰器注册
|
||||
|
||||
```typescript
|
||||
// 方式 1: 使用装饰器
|
||||
@RegisterNode(MyNodeTemplate)
|
||||
class MyNodeExecutor implements INodeExecutor { ... }
|
||||
|
||||
// 方式 2: 手动注册
|
||||
NodeRegistry.instance.register(MyNodeTemplate, new MyNodeExecutor());
|
||||
```
|
||||
|
||||
## 节点注册表
|
||||
|
||||
```typescript
|
||||
import { NodeRegistry } from '@esengine/blueprint';
|
||||
|
||||
// 获取单例
|
||||
const registry = NodeRegistry.instance;
|
||||
|
||||
// 获取所有模板
|
||||
const allTemplates = registry.getAllTemplates();
|
||||
|
||||
// 按类别获取
|
||||
const mathNodes = registry.getTemplatesByCategory('math');
|
||||
|
||||
// 搜索节点
|
||||
const results = registry.searchTemplates('add');
|
||||
|
||||
// 检查是否存在
|
||||
if (registry.has('MyCustomNode')) { ... }
|
||||
```
|
||||
|
||||
## 内置节点
|
||||
|
||||
### 事件节点
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `EventBeginPlay` | 蓝图启动时触发 |
|
||||
| `EventTick` | 每帧触发 |
|
||||
| `EventEndPlay` | 蓝图停止时触发 |
|
||||
| `EventCollision` | 碰撞时触发 |
|
||||
| `EventInput` | 输入事件触发 |
|
||||
| `EventTimer` | 定时器触发 |
|
||||
| `EventMessage` | 自定义消息触发 |
|
||||
|
||||
### 时间节点
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `Delay` | 延迟执行 |
|
||||
| `GetDeltaTime` | 获取帧间隔 |
|
||||
| `GetTime` | 获取运行时间 |
|
||||
|
||||
### 数学节点
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `Add` | 加法 |
|
||||
| `Subtract` | 减法 |
|
||||
| `Multiply` | 乘法 |
|
||||
| `Divide` | 除法 |
|
||||
| `Abs` | 绝对值 |
|
||||
| `Clamp` | 限制范围 |
|
||||
| `Lerp` | 线性插值 |
|
||||
| `Min` / `Max` | 最小/最大值 |
|
||||
|
||||
### 调试节点
|
||||
|
||||
| 节点 | 说明 |
|
||||
|------|------|
|
||||
| `Print` | 打印到控制台 |
|
||||
|
||||
## 蓝图组合
|
||||
|
||||
### 蓝图片段
|
||||
|
||||
将可复用的逻辑封装为片段:
|
||||
|
||||
```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: [...]
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 组合蓝图
|
||||
|
||||
```typescript
|
||||
import { createComposer, FragmentRegistry } from '@esengine/blueprint';
|
||||
|
||||
// 注册片段
|
||||
FragmentRegistry.instance.register('health', healthFragment);
|
||||
FragmentRegistry.instance.register('movement', movementFragment);
|
||||
|
||||
// 创建组合器
|
||||
const composer = createComposer('PlayerBlueprint');
|
||||
|
||||
// 添加片段到槽位
|
||||
composer.addFragment(healthFragment, 'slot1', { position: { x: 0, y: 0 } });
|
||||
composer.addFragment(movementFragment, 'slot2', { position: { x: 400, y: 0 } });
|
||||
|
||||
// 连接槽位
|
||||
composer.connect('slot1', 'onDeath', 'slot2', 'disable');
|
||||
|
||||
// 验证
|
||||
const validation = composer.validate();
|
||||
if (!validation.isValid) {
|
||||
console.error(validation.errors);
|
||||
}
|
||||
|
||||
// 编译成蓝图
|
||||
const blueprint = composer.compile();
|
||||
```
|
||||
|
||||
## 触发器系统
|
||||
|
||||
### 定义触发条件
|
||||
|
||||
```typescript
|
||||
import { TriggerCondition, TriggerDispatcher } from '@esengine/blueprint';
|
||||
|
||||
const lowHealthCondition: TriggerCondition = {
|
||||
type: 'comparison',
|
||||
left: { type: 'variable', name: 'health' },
|
||||
operator: '<',
|
||||
right: { type: 'constant', value: 20 }
|
||||
};
|
||||
```
|
||||
|
||||
### 使用触发器分发器
|
||||
|
||||
```typescript
|
||||
const dispatcher = new TriggerDispatcher();
|
||||
|
||||
// 注册触发器
|
||||
dispatcher.register('lowHealth', lowHealthCondition, (context) => {
|
||||
context.triggerEvent('OnLowHealth');
|
||||
});
|
||||
|
||||
// 每帧评估
|
||||
dispatcher.evaluate(context);
|
||||
```
|
||||
|
||||
## 与 ECS 集成
|
||||
|
||||
### 使用蓝图系统
|
||||
|
||||
```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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 触发蓝图事件
|
||||
|
||||
```typescript
|
||||
import { triggerBlueprintEvent, triggerCustomBlueprintEvent } from '@esengine/blueprint';
|
||||
|
||||
// 触发内置事件
|
||||
triggerBlueprintEvent(entity, 'Collision', { other: otherEntity });
|
||||
|
||||
// 触发自定义事件
|
||||
triggerCustomBlueprintEvent(entity, 'OnPickup', { item: itemEntity });
|
||||
```
|
||||
|
||||
## 实际示例
|
||||
|
||||
### 玩家控制蓝图
|
||||
|
||||
```typescript
|
||||
// 定义输入处理节点
|
||||
const InputMoveTemplate: BlueprintNodeTemplate = {
|
||||
type: 'InputMove',
|
||||
title: 'Get Movement Input',
|
||||
category: 'input',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'direction', type: 'vector2', direction: 'output' }
|
||||
],
|
||||
isPure: true
|
||||
};
|
||||
|
||||
@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 } };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 状态切换逻辑
|
||||
|
||||
```typescript
|
||||
// 在蓝图中实现状态机逻辑
|
||||
const stateBlueprint = createEmptyBlueprint('PlayerState');
|
||||
|
||||
// 添加状态变量
|
||||
stateBlueprint.variables.push({
|
||||
name: 'currentState',
|
||||
type: 'string',
|
||||
defaultValue: 'idle',
|
||||
scope: 'instance'
|
||||
});
|
||||
|
||||
// 在 Tick 事件中检查状态转换
|
||||
// ... 通过节点连接实现
|
||||
```
|
||||
|
||||
## 序列化
|
||||
|
||||
### 保存蓝图
|
||||
|
||||
```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);
|
||||
}
|
||||
```
|
||||
|
||||
### 加载蓝图
|
||||
|
||||
```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;
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用片段复用逻辑**
|
||||
- 将通用逻辑封装为片段
|
||||
- 通过组合器构建复杂蓝图
|
||||
|
||||
2. **合理使用变量作用域**
|
||||
- `local`: 临时计算结果
|
||||
- `instance`: 实体状态(如生命值)
|
||||
- `global`: 游戏全局状态
|
||||
|
||||
3. **避免无限循环**
|
||||
- VM 有每帧最大执行步数限制(默认 1000)
|
||||
- 使用 Delay 节点打断长执行链
|
||||
|
||||
4. **调试技巧**
|
||||
- 启用 `vm.debug = true` 查看执行日志
|
||||
- 使用 Print 节点输出中间值
|
||||
|
||||
5. **性能优化**
|
||||
- 纯节点(`isPure: true`)的输出会被缓存
|
||||
- 避免在 Tick 中执行重计算
|
||||
337
docs/modules/fsm/index.md
Normal file
337
docs/modules/fsm/index.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# 状态机 (FSM)
|
||||
|
||||
`@esengine/fsm` 提供了一个类型安全的有限状态机实现,用于角色、AI 或任何需要状态管理的场景。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install @esengine/fsm
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```typescript
|
||||
import { createStateMachine } from '@esengine/fsm';
|
||||
|
||||
// 定义状态类型
|
||||
type PlayerState = 'idle' | 'walk' | 'run' | 'jump';
|
||||
|
||||
// 创建状态机
|
||||
const fsm = createStateMachine<PlayerState>('idle');
|
||||
|
||||
// 定义状态和回调
|
||||
fsm.defineState('idle', {
|
||||
onEnter: (ctx, from) => console.log(`从 ${from} 进入 idle`),
|
||||
onExit: (ctx, to) => console.log(`从 idle 退出到 ${to}`),
|
||||
onUpdate: (ctx, dt) => { /* 每帧更新 */ }
|
||||
});
|
||||
|
||||
fsm.defineState('walk', {
|
||||
onEnter: () => console.log('开始行走')
|
||||
});
|
||||
|
||||
// 手动切换状态
|
||||
fsm.transition('walk');
|
||||
|
||||
console.log(fsm.current); // 'walk'
|
||||
```
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 状态配置
|
||||
|
||||
每个状态可以配置以下回调:
|
||||
|
||||
```typescript
|
||||
interface StateConfig<TState, TContext> {
|
||||
name: TState; // 状态名称
|
||||
onEnter?: (context: TContext, from: TState | null) => void; // 进入回调
|
||||
onExit?: (context: TContext, to: TState) => void; // 退出回调
|
||||
onUpdate?: (context: TContext, deltaTime: number) => void; // 更新回调
|
||||
tags?: string[]; // 状态标签
|
||||
metadata?: Record<string, unknown>; // 元数据
|
||||
}
|
||||
```
|
||||
|
||||
### 转换条件
|
||||
|
||||
可以定义带条件的状态转换:
|
||||
|
||||
```typescript
|
||||
interface Context {
|
||||
isMoving: boolean;
|
||||
isRunning: boolean;
|
||||
isGrounded: boolean;
|
||||
}
|
||||
|
||||
const fsm = createStateMachine<PlayerState, Context>('idle', {
|
||||
context: { isMoving: false, isRunning: false, isGrounded: true }
|
||||
});
|
||||
|
||||
// 定义转换条件
|
||||
fsm.defineTransition('idle', 'walk', (ctx) => ctx.isMoving);
|
||||
fsm.defineTransition('walk', 'run', (ctx) => ctx.isRunning);
|
||||
fsm.defineTransition('walk', 'idle', (ctx) => !ctx.isMoving);
|
||||
|
||||
// 自动评估并执行满足条件的转换
|
||||
fsm.evaluateTransitions();
|
||||
```
|
||||
|
||||
### 转换优先级
|
||||
|
||||
当多个转换条件同时满足时,优先级高的先执行:
|
||||
|
||||
```typescript
|
||||
// 优先级数字越大越优先
|
||||
fsm.defineTransition('idle', 'attack', (ctx) => ctx.isAttacking, 10);
|
||||
fsm.defineTransition('idle', 'walk', (ctx) => ctx.isMoving, 1);
|
||||
|
||||
// 如果同时满足,会先尝试 attack(优先级 10)
|
||||
```
|
||||
|
||||
## API 参考
|
||||
|
||||
### createStateMachine
|
||||
|
||||
```typescript
|
||||
function createStateMachine<TState extends string, TContext = unknown>(
|
||||
initialState: TState,
|
||||
options?: StateMachineOptions<TContext>
|
||||
): IStateMachine<TState, TContext>
|
||||
```
|
||||
|
||||
**参数:**
|
||||
- `initialState` - 初始状态
|
||||
- `options.context` - 上下文对象,在回调中可访问
|
||||
- `options.maxHistorySize` - 最大历史记录数(默认 100)
|
||||
- `options.enableHistory` - 是否启用历史记录(默认 true)
|
||||
|
||||
### 状态机属性
|
||||
|
||||
| 属性 | 类型 | 描述 |
|
||||
|------|------|------|
|
||||
| `current` | `TState` | 当前状态 |
|
||||
| `previous` | `TState \| null` | 上一个状态 |
|
||||
| `context` | `TContext` | 上下文对象 |
|
||||
| `isTransitioning` | `boolean` | 是否正在转换中 |
|
||||
| `currentStateDuration` | `number` | 当前状态持续时间(毫秒) |
|
||||
|
||||
### 状态机方法
|
||||
|
||||
#### 状态定义
|
||||
|
||||
```typescript
|
||||
// 定义状态
|
||||
fsm.defineState('idle', {
|
||||
onEnter: (ctx, from) => {},
|
||||
onExit: (ctx, to) => {},
|
||||
onUpdate: (ctx, dt) => {}
|
||||
});
|
||||
|
||||
// 检查状态是否存在
|
||||
fsm.hasState('idle'); // true
|
||||
|
||||
// 获取状态配置
|
||||
fsm.getStateConfig('idle');
|
||||
|
||||
// 获取所有状态
|
||||
fsm.getStates(); // ['idle', 'walk', ...]
|
||||
```
|
||||
|
||||
#### 转换操作
|
||||
|
||||
```typescript
|
||||
// 定义转换
|
||||
fsm.defineTransition('idle', 'walk', condition, priority);
|
||||
|
||||
// 移除转换
|
||||
fsm.removeTransition('idle', 'walk');
|
||||
|
||||
// 获取从某状态出发的所有转换
|
||||
fsm.getTransitionsFrom('idle');
|
||||
|
||||
// 检查是否可以转换
|
||||
fsm.canTransition('walk'); // true/false
|
||||
|
||||
// 手动转换
|
||||
fsm.transition('walk');
|
||||
|
||||
// 强制转换(忽略条件)
|
||||
fsm.transition('walk', true);
|
||||
|
||||
// 自动评估转换条件
|
||||
fsm.evaluateTransitions();
|
||||
```
|
||||
|
||||
#### 生命周期
|
||||
|
||||
```typescript
|
||||
// 更新状态机(调用当前状态的 onUpdate)
|
||||
fsm.update(deltaTime);
|
||||
|
||||
// 重置状态机
|
||||
fsm.reset(); // 重置到当前状态
|
||||
fsm.reset('idle'); // 重置到指定状态
|
||||
```
|
||||
|
||||
#### 事件监听
|
||||
|
||||
```typescript
|
||||
// 监听进入特定状态
|
||||
const unsubscribe = fsm.onEnter('walk', (from) => {
|
||||
console.log(`从 ${from} 进入 walk`);
|
||||
});
|
||||
|
||||
// 监听退出特定状态
|
||||
fsm.onExit('walk', (to) => {
|
||||
console.log(`从 walk 退出到 ${to}`);
|
||||
});
|
||||
|
||||
// 监听任意状态变化
|
||||
fsm.onChange((event) => {
|
||||
console.log(`${event.from} -> ${event.to} at ${event.timestamp}`);
|
||||
});
|
||||
|
||||
// 取消订阅
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
#### 调试
|
||||
|
||||
```typescript
|
||||
// 获取状态历史
|
||||
const history = fsm.getHistory();
|
||||
// [{ from: 'idle', to: 'walk', timestamp: 1234567890 }, ...]
|
||||
|
||||
// 清除历史
|
||||
fsm.clearHistory();
|
||||
|
||||
// 获取调试信息
|
||||
const info = fsm.getDebugInfo();
|
||||
// { current, previous, duration, stateCount, transitionCount, historySize }
|
||||
```
|
||||
|
||||
## 实际示例
|
||||
|
||||
### 角色状态机
|
||||
|
||||
```typescript
|
||||
import { createStateMachine } from '@esengine/fsm';
|
||||
|
||||
type CharacterState = 'idle' | 'walk' | 'run' | 'jump' | 'fall' | 'attack';
|
||||
|
||||
interface CharacterContext {
|
||||
velocity: { x: number; y: number };
|
||||
isGrounded: boolean;
|
||||
isAttacking: boolean;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
const characterFSM = createStateMachine<CharacterState, CharacterContext>('idle', {
|
||||
context: {
|
||||
velocity: { x: 0, y: 0 },
|
||||
isGrounded: true,
|
||||
isAttacking: false,
|
||||
speed: 0
|
||||
}
|
||||
});
|
||||
|
||||
// 定义状态
|
||||
characterFSM.defineState('idle', {
|
||||
onEnter: (ctx) => {
|
||||
ctx.speed = 0;
|
||||
},
|
||||
onUpdate: (ctx, dt) => {
|
||||
// 播放待机动画
|
||||
}
|
||||
});
|
||||
|
||||
characterFSM.defineState('walk', {
|
||||
onEnter: (ctx) => {
|
||||
ctx.speed = 100;
|
||||
}
|
||||
});
|
||||
|
||||
characterFSM.defineState('run', {
|
||||
onEnter: (ctx) => {
|
||||
ctx.speed = 200;
|
||||
}
|
||||
});
|
||||
|
||||
characterFSM.defineState('jump', {
|
||||
onEnter: (ctx) => {
|
||||
ctx.velocity.y = -300;
|
||||
ctx.isGrounded = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 定义转换
|
||||
characterFSM.defineTransition('idle', 'walk', (ctx) => Math.abs(ctx.velocity.x) > 0);
|
||||
characterFSM.defineTransition('walk', 'idle', (ctx) => ctx.velocity.x === 0);
|
||||
characterFSM.defineTransition('walk', 'run', (ctx) => Math.abs(ctx.velocity.x) > 150);
|
||||
characterFSM.defineTransition('run', 'walk', (ctx) => Math.abs(ctx.velocity.x) <= 150);
|
||||
|
||||
// 跳跃有最高优先级
|
||||
characterFSM.defineTransition('idle', 'jump', (ctx) => !ctx.isGrounded, 10);
|
||||
characterFSM.defineTransition('walk', 'jump', (ctx) => !ctx.isGrounded, 10);
|
||||
characterFSM.defineTransition('run', 'jump', (ctx) => !ctx.isGrounded, 10);
|
||||
|
||||
characterFSM.defineTransition('jump', 'fall', (ctx) => ctx.velocity.y > 0);
|
||||
characterFSM.defineTransition('fall', 'idle', (ctx) => ctx.isGrounded);
|
||||
|
||||
// 游戏循环中使用
|
||||
function gameUpdate(dt: number) {
|
||||
// 更新上下文
|
||||
characterFSM.context.velocity.x = getInputVelocity();
|
||||
characterFSM.context.isGrounded = checkGrounded();
|
||||
|
||||
// 评估状态转换
|
||||
characterFSM.evaluateTransitions();
|
||||
|
||||
// 更新当前状态
|
||||
characterFSM.update(dt);
|
||||
}
|
||||
```
|
||||
|
||||
### 与 ECS 集成
|
||||
|
||||
```typescript
|
||||
import { Component, EntitySystem, Matcher } from '@esengine/ecs-framework';
|
||||
import { createStateMachine, type IStateMachine } from '@esengine/fsm';
|
||||
|
||||
// 状态机组件
|
||||
class FSMComponent extends Component {
|
||||
fsm: IStateMachine<string>;
|
||||
|
||||
constructor(initialState: string) {
|
||||
super();
|
||||
this.fsm = createStateMachine(initialState);
|
||||
}
|
||||
}
|
||||
|
||||
// 状态机系统
|
||||
class FSMSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.all(FSMComponent));
|
||||
}
|
||||
|
||||
protected processEntity(entity: Entity, dt: number): void {
|
||||
const fsmComp = entity.getComponent(FSMComponent);
|
||||
fsmComp.fsm.evaluateTransitions();
|
||||
fsmComp.fsm.update(dt);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 蓝图节点
|
||||
|
||||
FSM 模块提供了可视化脚本支持的蓝图节点:
|
||||
|
||||
- `GetCurrentState` - 获取当前状态
|
||||
- `TransitionTo` - 转换到指定状态
|
||||
- `CanTransition` - 检查是否可以转换
|
||||
- `IsInState` - 检查是否在指定状态
|
||||
- `WasInState` - 检查是否曾在指定状态
|
||||
- `GetStateDuration` - 获取状态持续时间
|
||||
- `EvaluateTransitions` - 评估转换条件
|
||||
- `ResetStateMachine` - 重置状态机
|
||||
54
docs/modules/index.md
Normal file
54
docs/modules/index.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 功能模块
|
||||
|
||||
ESEngine 提供了丰富的功能模块,可以按需引入到你的项目中。
|
||||
|
||||
## 模块列表
|
||||
|
||||
### AI 模块
|
||||
|
||||
| 模块 | 包名 | 描述 |
|
||||
|------|------|------|
|
||||
| [行为树](/modules/behavior-tree/) | `@esengine/behavior-tree` | AI 行为树系统,支持可视化编辑 |
|
||||
| [状态机](/modules/fsm/) | `@esengine/fsm` | 有限状态机,用于角色/AI 状态管理 |
|
||||
|
||||
### 游戏逻辑
|
||||
|
||||
| 模块 | 包名 | 描述 |
|
||||
|------|------|------|
|
||||
| [定时器](/modules/timer/) | `@esengine/timer` | 定时器和冷却系统 |
|
||||
| [空间索引](/modules/spatial/) | `@esengine/spatial` | 空间查询、AOI 兴趣区域管理 |
|
||||
| [寻路系统](/modules/pathfinding/) | `@esengine/pathfinding` | A* 寻路、NavMesh 导航网格 |
|
||||
|
||||
### 工具模块
|
||||
|
||||
| 模块 | 包名 | 描述 |
|
||||
|------|------|------|
|
||||
| [可视化脚本](/modules/blueprint/) | `@esengine/blueprint` | 蓝图可视化脚本系统 |
|
||||
| [程序化生成](/modules/procgen/) | `@esengine/procgen` | 噪声函数、随机工具 |
|
||||
|
||||
### 网络模块
|
||||
|
||||
| 模块 | 包名 | 描述 |
|
||||
|------|------|------|
|
||||
| [网络同步](/modules/network/) | `@esengine/network` | 多人游戏网络同步 |
|
||||
|
||||
## 安装
|
||||
|
||||
所有模块都可以独立安装:
|
||||
|
||||
```bash
|
||||
# 安装单个模块
|
||||
npm install @esengine/behavior-tree
|
||||
|
||||
# 或使用 CLI 添加到现有项目
|
||||
npx @esengine/cli add behavior-tree
|
||||
```
|
||||
|
||||
## 平台兼容性
|
||||
|
||||
所有功能模块都是纯 TypeScript 实现,兼容:
|
||||
|
||||
- Cocos Creator 3.x
|
||||
- Laya 3.x
|
||||
- Node.js
|
||||
- 浏览器
|
||||
727
docs/modules/network/index.md
Normal file
727
docs/modules/network/index.md
Normal file
@@ -0,0 +1,727 @@
|
||||
# 网络同步系统 (Network)
|
||||
|
||||
`@esengine/network` 提供基于 TSRPC 的客户端-服务器网络同步解决方案,用于多人游戏的实体同步、输入处理和状态插值。
|
||||
|
||||
## 概述
|
||||
|
||||
网络模块由三个包组成:
|
||||
|
||||
| 包名 | 描述 |
|
||||
|------|------|
|
||||
| `@esengine/network` | 客户端 ECS 插件 |
|
||||
| `@esengine/network-protocols` | 共享协议定义 |
|
||||
| `@esengine/network-server` | 服务器端实现 |
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
# 客户端
|
||||
npm install @esengine/network
|
||||
|
||||
# 服务器端
|
||||
npm install @esengine/network-server
|
||||
```
|
||||
|
||||
## 使用 CLI 快速创建服务端
|
||||
|
||||
推荐使用 ESEngine CLI 快速创建完整的游戏服务端项目:
|
||||
|
||||
```bash
|
||||
# 创建项目目录
|
||||
mkdir my-game-server && cd my-game-server
|
||||
npm init -y
|
||||
|
||||
# 使用 CLI 初始化 Node.js 服务端
|
||||
npx @esengine/cli init -p nodejs
|
||||
```
|
||||
|
||||
CLI 会自动生成以下项目结构:
|
||||
|
||||
```
|
||||
my-game-server/
|
||||
├── src/
|
||||
│ ├── index.ts # 入口文件
|
||||
│ ├── server/
|
||||
│ │ └── GameServer.ts # 网络服务器配置
|
||||
│ └── game/
|
||||
│ ├── Game.ts # ECS 游戏主类
|
||||
│ ├── scenes/
|
||||
│ │ └── MainScene.ts # 主场景
|
||||
│ ├── components/ # ECS 组件
|
||||
│ │ ├── PositionComponent.ts
|
||||
│ │ └── VelocityComponent.ts
|
||||
│ └── systems/ # ECS 系统
|
||||
│ └── MovementSystem.ts
|
||||
├── tsconfig.json
|
||||
├── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
启动服务端:
|
||||
|
||||
```bash
|
||||
# 开发模式(热重载)
|
||||
npm run dev
|
||||
|
||||
# 生产模式
|
||||
npm run start
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 客户端
|
||||
|
||||
```typescript
|
||||
import { Core, Scene } from '@esengine/ecs-framework';
|
||||
import {
|
||||
NetworkPlugin,
|
||||
NetworkIdentity,
|
||||
NetworkTransform
|
||||
} from '@esengine/network';
|
||||
|
||||
// 定义游戏场景
|
||||
class GameScene extends Scene {
|
||||
initialize(): void {
|
||||
this.name = 'Game';
|
||||
// 网络系统由 NetworkPlugin 自动添加
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 Core
|
||||
Core.create({ debug: false });
|
||||
const scene = new GameScene();
|
||||
Core.setScene(scene);
|
||||
|
||||
// 安装网络插件
|
||||
const networkPlugin = new NetworkPlugin();
|
||||
await Core.installPlugin(networkPlugin);
|
||||
|
||||
// 注册预制体工厂
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
const identity = entity.addComponent(new NetworkIdentity());
|
||||
identity.netId = spawn.netId;
|
||||
identity.ownerId = spawn.ownerId;
|
||||
identity.isLocalPlayer = spawn.ownerId === networkPlugin.networkService.localClientId;
|
||||
|
||||
entity.addComponent(new NetworkTransform());
|
||||
return entity;
|
||||
});
|
||||
|
||||
// 连接服务器
|
||||
const success = await networkPlugin.connect('ws://localhost:3000', 'PlayerName');
|
||||
if (success) {
|
||||
console.log('Connected!');
|
||||
}
|
||||
|
||||
// 游戏循环
|
||||
function gameLoop(dt: number) {
|
||||
Core.update(dt);
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
await networkPlugin.disconnect();
|
||||
```
|
||||
|
||||
### 服务器端
|
||||
|
||||
使用 CLI 创建服务端项目后,默认生成的代码已经配置好了 GameServer:
|
||||
|
||||
```typescript
|
||||
import { GameServer } from '@esengine/network-server';
|
||||
|
||||
const server = new GameServer({
|
||||
port: 3000,
|
||||
roomConfig: {
|
||||
maxPlayers: 16,
|
||||
tickRate: 20
|
||||
}
|
||||
});
|
||||
|
||||
await server.start();
|
||||
console.log('Server started on ws://localhost:3000');
|
||||
```
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 架构
|
||||
|
||||
```
|
||||
客户端 服务器
|
||||
┌────────────────┐ ┌────────────────┐
|
||||
│ NetworkPlugin │◄──── WS ────► │ GameServer │
|
||||
│ ├─ Service │ │ ├─ Room │
|
||||
│ ├─ SyncSystem │ │ └─ Players │
|
||||
│ ├─ SpawnSystem │ └────────────────┘
|
||||
│ └─ InputSystem │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
### 组件
|
||||
|
||||
#### NetworkIdentity
|
||||
|
||||
网络标识组件,每个网络同步的实体必须拥有:
|
||||
|
||||
```typescript
|
||||
class NetworkIdentity extends Component {
|
||||
netId: number; // 网络唯一 ID
|
||||
ownerId: number; // 所有者客户端 ID
|
||||
bIsLocalPlayer: boolean; // 是否为本地玩家
|
||||
bHasAuthority: boolean; // 是否有权限控制
|
||||
}
|
||||
```
|
||||
|
||||
#### NetworkTransform
|
||||
|
||||
网络变换组件,用于位置和旋转同步:
|
||||
|
||||
```typescript
|
||||
class NetworkTransform extends Component {
|
||||
position: { x: number; y: number };
|
||||
rotation: number;
|
||||
velocity: { x: number; y: number };
|
||||
}
|
||||
```
|
||||
|
||||
### 系统
|
||||
|
||||
#### NetworkSyncSystem
|
||||
|
||||
处理服务器状态同步和插值:
|
||||
|
||||
- 接收服务器状态快照
|
||||
- 将状态存入快照缓冲区
|
||||
- 对远程实体进行插值平滑
|
||||
|
||||
#### NetworkSpawnSystem
|
||||
|
||||
处理实体的网络生成和销毁:
|
||||
|
||||
- 监听 Spawn/Despawn 消息
|
||||
- 使用注册的预制体工厂创建实体
|
||||
- 管理网络实体的生命周期
|
||||
|
||||
#### NetworkInputSystem
|
||||
|
||||
处理本地玩家输入的网络发送:
|
||||
|
||||
- 收集本地玩家输入
|
||||
- 发送输入到服务器
|
||||
- 支持移动和动作输入
|
||||
|
||||
## API 参考
|
||||
|
||||
### NetworkPlugin
|
||||
|
||||
```typescript
|
||||
class NetworkPlugin {
|
||||
constructor(config: INetworkPluginConfig);
|
||||
|
||||
// 安装插件
|
||||
install(services: ServiceContainer): void;
|
||||
|
||||
// 连接服务器
|
||||
connect(playerName: string, roomId?: string): Promise<void>;
|
||||
|
||||
// 断开连接
|
||||
disconnect(): void;
|
||||
|
||||
// 注册预制体工厂
|
||||
registerPrefab(prefab: string, factory: PrefabFactory): void;
|
||||
|
||||
// 属性
|
||||
readonly localPlayerId: number | null;
|
||||
readonly isConnected: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**配置选项:**
|
||||
|
||||
| 属性 | 类型 | 必需 | 描述 |
|
||||
|------|------|------|------|
|
||||
| `serverUrl` | `string` | 是 | WebSocket 服务器地址 |
|
||||
|
||||
### NetworkService
|
||||
|
||||
网络服务,管理 WebSocket 连接:
|
||||
|
||||
```typescript
|
||||
class NetworkService {
|
||||
// 连接状态
|
||||
readonly state: ENetworkState;
|
||||
readonly isConnected: boolean;
|
||||
readonly clientId: number | null;
|
||||
readonly roomId: string | null;
|
||||
|
||||
// 连接控制
|
||||
connect(serverUrl: string): Promise<void>;
|
||||
disconnect(): void;
|
||||
|
||||
// 加入房间
|
||||
join(playerName: string, roomId?: string): Promise<ResJoin>;
|
||||
|
||||
// 发送输入
|
||||
sendInput(input: IPlayerInput): void;
|
||||
|
||||
// 事件回调
|
||||
setCallbacks(callbacks: Partial<INetworkCallbacks>): void;
|
||||
}
|
||||
```
|
||||
|
||||
**网络状态枚举:**
|
||||
|
||||
```typescript
|
||||
enum ENetworkState {
|
||||
Disconnected = 'disconnected',
|
||||
Connecting = 'connecting',
|
||||
Connected = 'connected',
|
||||
Joining = 'joining',
|
||||
Joined = 'joined'
|
||||
}
|
||||
```
|
||||
|
||||
**回调接口:**
|
||||
|
||||
```typescript
|
||||
interface INetworkCallbacks {
|
||||
onConnected?: () => void;
|
||||
onDisconnected?: () => void;
|
||||
onJoined?: (clientId: number, roomId: string) => void;
|
||||
onSync?: (msg: MsgSync) => void;
|
||||
onSpawn?: (msg: MsgSpawn) => void;
|
||||
onDespawn?: (msg: MsgDespawn) => void;
|
||||
}
|
||||
```
|
||||
|
||||
### 预制体工厂
|
||||
|
||||
```typescript
|
||||
type PrefabFactory = (scene: Scene, spawn: MsgSpawn) => Entity;
|
||||
```
|
||||
|
||||
注册预制体工厂用于网络实体的创建:
|
||||
|
||||
```typescript
|
||||
networkPlugin.registerPrefab('enemy', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`enemy_${spawn.netId}`);
|
||||
|
||||
const identity = entity.addComponent(new NetworkIdentity());
|
||||
identity.netId = spawn.netId;
|
||||
identity.ownerId = spawn.ownerId;
|
||||
|
||||
entity.addComponent(new NetworkTransform());
|
||||
entity.addComponent(new EnemyComponent());
|
||||
return entity;
|
||||
});
|
||||
```
|
||||
|
||||
### 输入系统
|
||||
|
||||
#### NetworkInputSystem
|
||||
|
||||
```typescript
|
||||
class NetworkInputSystem extends EntitySystem {
|
||||
// 添加移动输入
|
||||
addMoveInput(x: number, y: number): void;
|
||||
|
||||
// 添加动作输入
|
||||
addActionInput(action: string): void;
|
||||
|
||||
// 清除输入
|
||||
clearInput(): void;
|
||||
}
|
||||
```
|
||||
|
||||
使用示例:
|
||||
|
||||
```typescript
|
||||
// 通过 NetworkPlugin 发送输入(推荐)
|
||||
networkPlugin.sendMoveInput(0, 1); // 移动
|
||||
networkPlugin.sendActionInput('jump'); // 动作
|
||||
|
||||
// 或直接使用 inputSystem
|
||||
const inputSystem = networkPlugin.inputSystem;
|
||||
if (keyboard.isPressed('W')) {
|
||||
inputSystem.addMoveInput(0, 1);
|
||||
}
|
||||
if (keyboard.isPressed('Space')) {
|
||||
inputSystem.addActionInput('jump');
|
||||
}
|
||||
```
|
||||
|
||||
## 状态同步
|
||||
|
||||
### 快照缓冲区
|
||||
|
||||
用于存储服务器状态快照并进行插值:
|
||||
|
||||
```typescript
|
||||
import { createSnapshotBuffer, type IStateSnapshot } from '@esengine/network';
|
||||
|
||||
const buffer = createSnapshotBuffer<IStateSnapshot>({
|
||||
maxSnapshots: 30, // 最大快照数
|
||||
interpolationDelay: 100 // 插值延迟 (ms)
|
||||
});
|
||||
|
||||
// 添加快照
|
||||
buffer.addSnapshot({
|
||||
time: serverTime,
|
||||
entities: states
|
||||
});
|
||||
|
||||
// 获取插值状态
|
||||
const interpolated = buffer.getInterpolatedState(clientTime);
|
||||
```
|
||||
|
||||
### 变换插值器
|
||||
|
||||
#### 线性插值器
|
||||
|
||||
```typescript
|
||||
import { createTransformInterpolator } from '@esengine/network';
|
||||
|
||||
const interpolator = createTransformInterpolator();
|
||||
|
||||
// 添加状态
|
||||
interpolator.addState(time, { x: 0, y: 0, rotation: 0 });
|
||||
|
||||
// 获取插值结果
|
||||
const state = interpolator.getInterpolatedState(currentTime);
|
||||
```
|
||||
|
||||
#### Hermite 插值器
|
||||
|
||||
使用 Hermite 样条实现更平滑的插值:
|
||||
|
||||
```typescript
|
||||
import { createHermiteTransformInterpolator } from '@esengine/network';
|
||||
|
||||
const interpolator = createHermiteTransformInterpolator({
|
||||
bufferSize: 10
|
||||
});
|
||||
|
||||
// 添加带速度的状态
|
||||
interpolator.addState(time, {
|
||||
x: 100,
|
||||
y: 200,
|
||||
rotation: 0,
|
||||
vx: 5,
|
||||
vy: 0
|
||||
});
|
||||
|
||||
// 获取平滑的插值结果
|
||||
const state = interpolator.getInterpolatedState(currentTime);
|
||||
```
|
||||
|
||||
### 客户端预测
|
||||
|
||||
实现客户端预测和服务器校正:
|
||||
|
||||
```typescript
|
||||
import { createClientPrediction } from '@esengine/network';
|
||||
|
||||
const prediction = createClientPrediction({
|
||||
maxPredictedInputs: 60,
|
||||
reconciliationThreshold: 0.1
|
||||
});
|
||||
|
||||
// 预测输入
|
||||
const seq = prediction.predict(inputState, currentState, (state, input) => {
|
||||
// 应用输入到状态
|
||||
return applyInput(state, input);
|
||||
});
|
||||
|
||||
// 服务器校正
|
||||
const corrected = prediction.reconcile(
|
||||
serverState,
|
||||
serverSeq,
|
||||
(state, input) => applyInput(state, input)
|
||||
);
|
||||
```
|
||||
|
||||
## 服务器端
|
||||
|
||||
### GameServer
|
||||
|
||||
```typescript
|
||||
import { GameServer } from '@esengine/network-server';
|
||||
|
||||
const server = new GameServer({
|
||||
port: 3000,
|
||||
roomConfig: {
|
||||
maxPlayers: 16, // 房间最大玩家数
|
||||
tickRate: 20 // 同步频率 (Hz)
|
||||
}
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
await server.start();
|
||||
|
||||
// 获取房间
|
||||
const room = server.getOrCreateRoom('room-id');
|
||||
|
||||
// 停止服务器
|
||||
await server.stop();
|
||||
```
|
||||
|
||||
### Room
|
||||
|
||||
```typescript
|
||||
class Room {
|
||||
readonly id: string;
|
||||
readonly playerCount: number;
|
||||
readonly isFull: boolean;
|
||||
|
||||
// 添加玩家
|
||||
addPlayer(name: string, connection: Connection): IPlayer | null;
|
||||
|
||||
// 移除玩家
|
||||
removePlayer(clientId: number): void;
|
||||
|
||||
// 获取玩家
|
||||
getPlayer(clientId: number): IPlayer | undefined;
|
||||
|
||||
// 处理输入
|
||||
handleInput(clientId: number, input: IPlayerInput): void;
|
||||
|
||||
// 销毁房间
|
||||
destroy(): void;
|
||||
}
|
||||
```
|
||||
|
||||
**玩家接口:**
|
||||
|
||||
```typescript
|
||||
interface IPlayer {
|
||||
clientId: number; // 客户端 ID
|
||||
name: string; // 玩家名称
|
||||
connection: Connection; // 连接对象
|
||||
netId: number; // 网络实体 ID
|
||||
}
|
||||
```
|
||||
|
||||
## 协议类型
|
||||
|
||||
### 消息类型
|
||||
|
||||
```typescript
|
||||
// 状态同步消息
|
||||
interface MsgSync {
|
||||
time: number;
|
||||
entities: IEntityState[];
|
||||
}
|
||||
|
||||
// 实体状态
|
||||
interface IEntityState {
|
||||
netId: number;
|
||||
pos?: Vec2;
|
||||
rot?: number;
|
||||
}
|
||||
|
||||
// 生成消息
|
||||
interface MsgSpawn {
|
||||
netId: number;
|
||||
ownerId: number;
|
||||
prefab: string;
|
||||
pos: Vec2;
|
||||
rot: number;
|
||||
}
|
||||
|
||||
// 销毁消息
|
||||
interface MsgDespawn {
|
||||
netId: number;
|
||||
}
|
||||
|
||||
// 输入消息
|
||||
interface MsgInput {
|
||||
input: IPlayerInput;
|
||||
}
|
||||
|
||||
// 玩家输入
|
||||
interface IPlayerInput {
|
||||
seq?: number;
|
||||
moveDir?: Vec2;
|
||||
actions?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### API 类型
|
||||
|
||||
```typescript
|
||||
// 加入请求
|
||||
interface ReqJoin {
|
||||
playerName: string;
|
||||
roomId?: string;
|
||||
}
|
||||
|
||||
// 加入响应
|
||||
interface ResJoin {
|
||||
clientId: number;
|
||||
roomId: string;
|
||||
playerCount: number;
|
||||
}
|
||||
```
|
||||
|
||||
## 蓝图节点
|
||||
|
||||
网络模块提供了可视化脚本支持的蓝图节点:
|
||||
|
||||
- `IsLocalPlayer` - 检查实体是否为本地玩家
|
||||
- `IsServer` - 检查是否运行在服务器端
|
||||
- `HasAuthority` - 检查是否有权限控制实体
|
||||
- `GetNetworkId` - 获取实体的网络 ID
|
||||
- `GetLocalPlayerId` - 获取本地玩家 ID
|
||||
|
||||
## 服务令牌
|
||||
|
||||
用于依赖注入:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
NetworkServiceToken,
|
||||
NetworkSyncSystemToken,
|
||||
NetworkSpawnSystemToken,
|
||||
NetworkInputSystemToken
|
||||
} from '@esengine/network';
|
||||
|
||||
// 获取服务
|
||||
const networkService = services.get(NetworkServiceToken);
|
||||
```
|
||||
|
||||
## 实际示例
|
||||
|
||||
### 完整的多人游戏客户端
|
||||
|
||||
```typescript
|
||||
import { Core, Scene, EntitySystem, Matcher, Entity } from '@esengine/ecs-framework';
|
||||
import {
|
||||
NetworkPlugin,
|
||||
NetworkIdentity,
|
||||
NetworkTransform
|
||||
} from '@esengine/network';
|
||||
|
||||
// 定义游戏场景
|
||||
class GameScene extends Scene {
|
||||
initialize(): void {
|
||||
this.name = 'MultiplayerGame';
|
||||
// 网络系统由 NetworkPlugin 自动添加
|
||||
// 添加自定义系统
|
||||
this.addSystem(new LocalInputHandler());
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
async function initGame() {
|
||||
Core.create({ debug: false });
|
||||
|
||||
const scene = new GameScene();
|
||||
Core.setScene(scene);
|
||||
|
||||
// 安装网络插件
|
||||
const networkPlugin = new NetworkPlugin();
|
||||
await Core.installPlugin(networkPlugin);
|
||||
|
||||
// 注册玩家预制体
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
const identity = entity.addComponent(new NetworkIdentity());
|
||||
identity.netId = spawn.netId;
|
||||
identity.ownerId = spawn.ownerId;
|
||||
identity.isLocalPlayer = spawn.ownerId === networkPlugin.networkService.localClientId;
|
||||
|
||||
entity.addComponent(new NetworkTransform());
|
||||
|
||||
// 如果是本地玩家,添加输入标记
|
||||
if (identity.isLocalPlayer) {
|
||||
entity.addComponent(new LocalInputComponent());
|
||||
}
|
||||
|
||||
return entity;
|
||||
});
|
||||
|
||||
// 连接服务器
|
||||
const success = await networkPlugin.connect('ws://localhost:3000', 'Player1');
|
||||
if (success) {
|
||||
console.log('已连接!');
|
||||
} else {
|
||||
console.error('连接失败');
|
||||
}
|
||||
|
||||
return networkPlugin;
|
||||
}
|
||||
|
||||
// 游戏循环
|
||||
function gameLoop(deltaTime: number) {
|
||||
Core.update(deltaTime);
|
||||
}
|
||||
|
||||
initGame();
|
||||
```
|
||||
|
||||
### 处理输入
|
||||
|
||||
```typescript
|
||||
class LocalInputHandler extends EntitySystem {
|
||||
private _networkPlugin: NetworkPlugin | null = null;
|
||||
|
||||
constructor() {
|
||||
super(Matcher.empty().all(NetworkIdentity, LocalInputComponent));
|
||||
}
|
||||
|
||||
protected onAddedToScene(): void {
|
||||
// 获取 NetworkPlugin 引用
|
||||
this._networkPlugin = Core.getPlugin(NetworkPlugin);
|
||||
}
|
||||
|
||||
protected processEntity(entity: Entity, dt: number): void {
|
||||
if (!this._networkPlugin) return;
|
||||
|
||||
const identity = entity.getComponent(NetworkIdentity)!;
|
||||
if (!identity.isLocalPlayer) return;
|
||||
|
||||
// 读取键盘输入
|
||||
let moveX = 0;
|
||||
let moveY = 0;
|
||||
|
||||
if (keyboard.isPressed('A')) moveX -= 1;
|
||||
if (keyboard.isPressed('D')) moveX += 1;
|
||||
if (keyboard.isPressed('W')) moveY += 1;
|
||||
if (keyboard.isPressed('S')) moveY -= 1;
|
||||
|
||||
if (moveX !== 0 || moveY !== 0) {
|
||||
this._networkPlugin.sendMoveInput(moveX, moveY);
|
||||
}
|
||||
|
||||
if (keyboard.isJustPressed('Space')) {
|
||||
this._networkPlugin.sendActionInput('jump');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **合理设置同步频率**:根据游戏类型选择合适的 `tickRate`,动作游戏通常需要 20-60 Hz
|
||||
|
||||
2. **使用插值延迟**:设置适当的 `interpolationDelay` 来平衡延迟和平滑度
|
||||
|
||||
3. **客户端预测**:对于本地玩家使用客户端预测减少输入延迟
|
||||
|
||||
4. **预制体管理**:为每种网络实体类型注册对应的预制体工厂
|
||||
|
||||
5. **权限检查**:使用 `bHasAuthority` 检查是否有权限修改实体
|
||||
|
||||
6. **连接状态**:监听连接状态变化,处理断线重连
|
||||
|
||||
```typescript
|
||||
networkService.setCallbacks({
|
||||
onConnected: () => console.log('已连接'),
|
||||
onDisconnected: () => {
|
||||
console.log('已断开');
|
||||
// 处理重连逻辑
|
||||
}
|
||||
});
|
||||
```
|
||||
502
docs/modules/pathfinding/index.md
Normal file
502
docs/modules/pathfinding/index.md
Normal file
@@ -0,0 +1,502 @@
|
||||
# 寻路系统 (Pathfinding)
|
||||
|
||||
`@esengine/pathfinding` 提供了完整的 2D 寻路解决方案,包括 A* 算法、网格地图、导航网格和路径平滑。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install @esengine/pathfinding
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 网格地图寻路
|
||||
|
||||
```typescript
|
||||
import { createGridMap, createAStarPathfinder } from '@esengine/pathfinding';
|
||||
|
||||
// 创建 20x20 的网格地图
|
||||
const grid = createGridMap(20, 20);
|
||||
|
||||
// 设置障碍物
|
||||
grid.setWalkable(5, 5, false);
|
||||
grid.setWalkable(5, 6, false);
|
||||
grid.setWalkable(5, 7, false);
|
||||
|
||||
// 创建寻路器
|
||||
const pathfinder = createAStarPathfinder(grid);
|
||||
|
||||
// 查找路径
|
||||
const result = pathfinder.findPath(0, 0, 15, 15);
|
||||
|
||||
if (result.found) {
|
||||
console.log('找到路径!');
|
||||
console.log('路径点:', result.path);
|
||||
console.log('总代价:', result.cost);
|
||||
console.log('搜索节点数:', result.nodesSearched);
|
||||
}
|
||||
```
|
||||
|
||||
### 导航网格寻路
|
||||
|
||||
```typescript
|
||||
import { createNavMesh } from '@esengine/pathfinding';
|
||||
|
||||
// 创建导航网格
|
||||
const navmesh = createNavMesh();
|
||||
|
||||
// 添加多边形区域
|
||||
navmesh.addPolygon([
|
||||
{ x: 0, y: 0 }, { x: 10, y: 0 },
|
||||
{ x: 10, y: 10 }, { x: 0, y: 10 }
|
||||
]);
|
||||
|
||||
navmesh.addPolygon([
|
||||
{ x: 10, y: 0 }, { x: 20, y: 0 },
|
||||
{ x: 20, y: 10 }, { x: 10, y: 10 }
|
||||
]);
|
||||
|
||||
// 自动建立连接
|
||||
navmesh.build();
|
||||
|
||||
// 寻路
|
||||
const result = navmesh.findPath(1, 1, 18, 8);
|
||||
```
|
||||
|
||||
## 核心概念
|
||||
|
||||
### IPoint - 坐标点
|
||||
|
||||
```typescript
|
||||
interface IPoint {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
```
|
||||
|
||||
### IPathResult - 寻路结果
|
||||
|
||||
```typescript
|
||||
interface IPathResult {
|
||||
readonly found: boolean; // 是否找到路径
|
||||
readonly path: readonly IPoint[]; // 路径点列表
|
||||
readonly cost: number; // 路径总代价
|
||||
readonly nodesSearched: number; // 搜索的节点数
|
||||
}
|
||||
```
|
||||
|
||||
### IPathfindingOptions - 寻路配置
|
||||
|
||||
```typescript
|
||||
interface IPathfindingOptions {
|
||||
maxNodes?: number; // 最大搜索节点数(默认 10000)
|
||||
heuristicWeight?: number; // 启发式权重(>1 更快但可能非最优)
|
||||
allowDiagonal?: boolean; // 是否允许对角移动(默认 true)
|
||||
avoidCorners?: boolean; // 是否避免穿角(默认 true)
|
||||
}
|
||||
```
|
||||
|
||||
## 启发式函数
|
||||
|
||||
模块提供了四种启发式函数:
|
||||
|
||||
| 函数 | 适用场景 | 说明 |
|
||||
|------|----------|------|
|
||||
| `manhattanDistance` | 4方向移动 | 曼哈顿距离,只考虑水平/垂直 |
|
||||
| `euclideanDistance` | 任意方向 | 欧几里得距离,直线距离 |
|
||||
| `chebyshevDistance` | 8方向移动 | 切比雪夫距离,对角线代价为 1 |
|
||||
| `octileDistance` | 8方向移动 | 八角距离,对角线代价为 √2(默认) |
|
||||
|
||||
```typescript
|
||||
import { manhattanDistance, octileDistance } from '@esengine/pathfinding';
|
||||
|
||||
// 自定义启发式
|
||||
const grid = createGridMap(20, 20, {
|
||||
heuristic: manhattanDistance // 使用曼哈顿距离
|
||||
});
|
||||
```
|
||||
|
||||
## 网格地图 API
|
||||
|
||||
### createGridMap
|
||||
|
||||
```typescript
|
||||
function createGridMap(
|
||||
width: number,
|
||||
height: number,
|
||||
options?: IGridMapOptions
|
||||
): GridMap
|
||||
```
|
||||
|
||||
**配置选项:**
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `allowDiagonal` | `boolean` | `true` | 允许对角移动 |
|
||||
| `diagonalCost` | `number` | `√2` | 对角移动代价 |
|
||||
| `avoidCorners` | `boolean` | `true` | 避免穿角 |
|
||||
| `heuristic` | `HeuristicFunction` | `octileDistance` | 启发式函数 |
|
||||
|
||||
### 地图操作
|
||||
|
||||
```typescript
|
||||
// 检查/设置可通行性
|
||||
grid.isWalkable(x, y);
|
||||
grid.setWalkable(x, y, false);
|
||||
|
||||
// 设置移动代价(如沼泽、沙地)
|
||||
grid.setCost(x, y, 2); // 代价为 2(默认 1)
|
||||
|
||||
// 设置矩形区域
|
||||
grid.setRectWalkable(0, 0, 5, 5, false);
|
||||
|
||||
// 从数组加载(0=可通行,非0=障碍)
|
||||
grid.loadFromArray([
|
||||
[0, 0, 0, 1, 0],
|
||||
[0, 1, 0, 1, 0],
|
||||
[0, 1, 0, 0, 0]
|
||||
]);
|
||||
|
||||
// 从字符串加载(.=可通行,#=障碍)
|
||||
grid.loadFromString(`
|
||||
.....
|
||||
.#.#.
|
||||
.#...
|
||||
`);
|
||||
|
||||
// 导出为字符串
|
||||
console.log(grid.toString());
|
||||
|
||||
// 重置所有节点为可通行
|
||||
grid.reset();
|
||||
```
|
||||
|
||||
### 方向常量
|
||||
|
||||
```typescript
|
||||
import { DIRECTIONS_4, DIRECTIONS_8 } from '@esengine/pathfinding';
|
||||
|
||||
// 4方向(上下左右)
|
||||
DIRECTIONS_4 // [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, ...]
|
||||
|
||||
// 8方向(含对角线)
|
||||
DIRECTIONS_8 // [{ dx: 0, dy: -1 }, { dx: 1, dy: -1 }, ...]
|
||||
```
|
||||
|
||||
## A* 寻路器 API
|
||||
|
||||
### createAStarPathfinder
|
||||
|
||||
```typescript
|
||||
function createAStarPathfinder(map: IPathfindingMap): AStarPathfinder
|
||||
```
|
||||
|
||||
### findPath
|
||||
|
||||
```typescript
|
||||
const result = pathfinder.findPath(
|
||||
startX, startY,
|
||||
endX, endY,
|
||||
{
|
||||
maxNodes: 5000, // 限制搜索节点数
|
||||
heuristicWeight: 1.5 // 加速但可能非最优
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### 重用寻路器
|
||||
|
||||
```typescript
|
||||
// 寻路器可重用,内部会自动清理状态
|
||||
pathfinder.findPath(0, 0, 10, 10);
|
||||
pathfinder.findPath(5, 5, 15, 15);
|
||||
|
||||
// 手动清理(可选)
|
||||
pathfinder.clear();
|
||||
```
|
||||
|
||||
## 导航网格 API
|
||||
|
||||
### createNavMesh
|
||||
|
||||
```typescript
|
||||
function createNavMesh(): NavMesh
|
||||
```
|
||||
|
||||
### 构建导航网格
|
||||
|
||||
```typescript
|
||||
const navmesh = createNavMesh();
|
||||
|
||||
// 添加凸多边形
|
||||
const id1 = navmesh.addPolygon([
|
||||
{ x: 0, y: 0 }, { x: 10, y: 0 },
|
||||
{ x: 10, y: 10 }, { x: 0, y: 10 }
|
||||
]);
|
||||
|
||||
const id2 = navmesh.addPolygon([
|
||||
{ x: 10, y: 0 }, { x: 20, y: 0 },
|
||||
{ x: 20, y: 10 }, { x: 10, y: 10 }
|
||||
]);
|
||||
|
||||
// 方式1:自动检测共享边并建立连接
|
||||
navmesh.build();
|
||||
|
||||
// 方式2:手动设置连接
|
||||
navmesh.setConnection(id1, id2, {
|
||||
left: { x: 10, y: 0 },
|
||||
right: { x: 10, y: 10 }
|
||||
});
|
||||
```
|
||||
|
||||
### 查询和寻路
|
||||
|
||||
```typescript
|
||||
// 查找包含点的多边形
|
||||
const polygon = navmesh.findPolygonAt(5, 5);
|
||||
|
||||
// 检查位置是否可通行
|
||||
navmesh.isWalkable(5, 5);
|
||||
|
||||
// 寻路(内部使用漏斗算法优化路径)
|
||||
const result = navmesh.findPath(1, 1, 18, 8);
|
||||
```
|
||||
|
||||
## 路径平滑 API
|
||||
|
||||
### 视线简化
|
||||
|
||||
移除不必要的中间点:
|
||||
|
||||
```typescript
|
||||
import { createLineOfSightSmoother } from '@esengine/pathfinding';
|
||||
|
||||
const smoother = createLineOfSightSmoother();
|
||||
const smoothedPath = smoother.smooth(result.path, grid);
|
||||
|
||||
// 原路径: [(0,0), (1,1), (2,2), (3,3), (4,4)]
|
||||
// 简化后: [(0,0), (4,4)]
|
||||
```
|
||||
|
||||
### 曲线平滑
|
||||
|
||||
使用 Catmull-Rom 样条曲线:
|
||||
|
||||
```typescript
|
||||
import { createCatmullRomSmoother } from '@esengine/pathfinding';
|
||||
|
||||
const smoother = createCatmullRomSmoother(
|
||||
5, // segments - 每段插值点数
|
||||
0.5 // tension - 张力 (0-1)
|
||||
);
|
||||
|
||||
const curvedPath = smoother.smooth(result.path, grid);
|
||||
```
|
||||
|
||||
### 组合平滑
|
||||
|
||||
先简化再曲线平滑:
|
||||
|
||||
```typescript
|
||||
import { createCombinedSmoother } from '@esengine/pathfinding';
|
||||
|
||||
const smoother = createCombinedSmoother(5, 0.5);
|
||||
const finalPath = smoother.smooth(result.path, grid);
|
||||
```
|
||||
|
||||
### 视线检测函数
|
||||
|
||||
```typescript
|
||||
import { bresenhamLineOfSight, raycastLineOfSight } from '@esengine/pathfinding';
|
||||
|
||||
// Bresenham 算法(快速,网格对齐)
|
||||
const hasLOS = bresenhamLineOfSight(x1, y1, x2, y2, grid);
|
||||
|
||||
// 射线投射(精确,支持浮点坐标)
|
||||
const hasLOS = raycastLineOfSight(x1, y1, x2, y2, grid, 0.5);
|
||||
```
|
||||
|
||||
## 实际示例
|
||||
|
||||
### 游戏角色移动
|
||||
|
||||
```typescript
|
||||
class MovementSystem {
|
||||
private grid: GridMap;
|
||||
private pathfinder: AStarPathfinder;
|
||||
private smoother: CombinedSmoother;
|
||||
|
||||
constructor(width: number, height: number) {
|
||||
this.grid = createGridMap(width, height);
|
||||
this.pathfinder = createAStarPathfinder(this.grid);
|
||||
this.smoother = createCombinedSmoother();
|
||||
}
|
||||
|
||||
findPath(from: IPoint, to: IPoint): IPoint[] | null {
|
||||
const result = this.pathfinder.findPath(
|
||||
from.x, from.y,
|
||||
to.x, to.y
|
||||
);
|
||||
|
||||
if (!result.found) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 平滑路径
|
||||
return this.smoother.smooth(result.path, this.grid);
|
||||
}
|
||||
|
||||
setObstacle(x: number, y: number): void {
|
||||
this.grid.setWalkable(x, y, false);
|
||||
}
|
||||
|
||||
setTerrain(x: number, y: number, cost: number): void {
|
||||
this.grid.setCost(x, y, cost);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 动态障碍物
|
||||
|
||||
```typescript
|
||||
class DynamicPathfinding {
|
||||
private grid: GridMap;
|
||||
private pathfinder: AStarPathfinder;
|
||||
private dynamicObstacles: Set<string> = new Set();
|
||||
|
||||
addDynamicObstacle(x: number, y: number): void {
|
||||
const key = `${x},${y}`;
|
||||
if (!this.dynamicObstacles.has(key)) {
|
||||
this.dynamicObstacles.add(key);
|
||||
this.grid.setWalkable(x, y, false);
|
||||
}
|
||||
}
|
||||
|
||||
removeDynamicObstacle(x: number, y: number): void {
|
||||
const key = `${x},${y}`;
|
||||
if (this.dynamicObstacles.has(key)) {
|
||||
this.dynamicObstacles.delete(key);
|
||||
this.grid.setWalkable(x, y, true);
|
||||
}
|
||||
}
|
||||
|
||||
findPath(from: IPoint, to: IPoint): IPathResult {
|
||||
return this.pathfinder.findPath(from.x, from.y, to.x, to.y);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 不同地形代价
|
||||
|
||||
```typescript
|
||||
// 设置不同地形的移动代价
|
||||
const grid = createGridMap(50, 50);
|
||||
|
||||
// 普通地面 - 代价 1(默认)
|
||||
// 沙地 - 代价 2
|
||||
for (let y = 10; y < 20; y++) {
|
||||
for (let x = 0; x < 50; x++) {
|
||||
grid.setCost(x, y, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// 沼泽 - 代价 4
|
||||
for (let y = 30; y < 35; y++) {
|
||||
for (let x = 20; x < 30; x++) {
|
||||
grid.setCost(x, y, 4);
|
||||
}
|
||||
}
|
||||
|
||||
// 寻路时会自动考虑地形代价
|
||||
const result = pathfinder.findPath(0, 0, 49, 49);
|
||||
```
|
||||
|
||||
### 分层寻路
|
||||
|
||||
对于大型地图,使用层级化寻路:
|
||||
|
||||
```typescript
|
||||
class HierarchicalPathfinding {
|
||||
private coarseGrid: GridMap; // 粗粒度网格
|
||||
private fineGrid: GridMap; // 细粒度网格
|
||||
private coarsePathfinder: AStarPathfinder;
|
||||
private finePathfinder: AStarPathfinder;
|
||||
private cellSize = 10;
|
||||
|
||||
findPath(from: IPoint, to: IPoint): IPoint[] {
|
||||
// 1. 在粗粒度网格上寻路
|
||||
const coarseFrom = this.toCoarse(from);
|
||||
const coarseTo = this.toCoarse(to);
|
||||
const coarseResult = this.coarsePathfinder.findPath(
|
||||
coarseFrom.x, coarseFrom.y,
|
||||
coarseTo.x, coarseTo.y
|
||||
);
|
||||
|
||||
if (!coarseResult.found) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2. 在每个粗粒度单元内进行细粒度寻路
|
||||
const finePath: IPoint[] = [];
|
||||
// ... 详细实现略
|
||||
return finePath;
|
||||
}
|
||||
|
||||
private toCoarse(p: IPoint): IPoint {
|
||||
return {
|
||||
x: Math.floor(p.x / this.cellSize),
|
||||
y: Math.floor(p.y / this.cellSize)
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 蓝图节点
|
||||
|
||||
Pathfinding 模块提供了可视化脚本支持的蓝图节点:
|
||||
|
||||
- `FindPath` - 查找路径
|
||||
- `FindPathSmooth` - 查找并平滑路径
|
||||
- `IsWalkable` - 检查位置是否可通行
|
||||
- `GetPathLength` - 获取路径点数
|
||||
- `GetPathDistance` - 获取路径总距离
|
||||
- `GetPathPoint` - 获取路径上的指定点
|
||||
- `MoveAlongPath` - 沿路径移动
|
||||
- `HasLineOfSight` - 检查视线
|
||||
|
||||
## 性能优化
|
||||
|
||||
1. **限制搜索范围**
|
||||
```typescript
|
||||
pathfinder.findPath(x1, y1, x2, y2, { maxNodes: 1000 });
|
||||
```
|
||||
|
||||
2. **使用启发式权重**
|
||||
```typescript
|
||||
// 权重 > 1 会更快但可能不是最优路径
|
||||
pathfinder.findPath(x1, y1, x2, y2, { heuristicWeight: 1.5 });
|
||||
```
|
||||
|
||||
3. **复用寻路器实例**
|
||||
```typescript
|
||||
// 创建一次,多次使用
|
||||
const pathfinder = createAStarPathfinder(grid);
|
||||
```
|
||||
|
||||
4. **使用导航网格**
|
||||
- 对于复杂地形,NavMesh 比网格寻路更高效
|
||||
- 多边形数量远少于网格单元格数量
|
||||
|
||||
5. **选择合适的启发式**
|
||||
- 4方向移动用 `manhattanDistance`
|
||||
- 8方向移动用 `octileDistance`(默认)
|
||||
|
||||
## 网格 vs 导航网格
|
||||
|
||||
| 特性 | GridMap | NavMesh |
|
||||
|------|---------|---------|
|
||||
| 适用场景 | 规则瓦片地图 | 复杂多边形地形 |
|
||||
| 内存占用 | 较高 (width × height) | 较低 (多边形数) |
|
||||
| 精度 | 网格对齐 | 连续坐标 |
|
||||
| 动态修改 | 容易 | 需要重建 |
|
||||
| 设置复杂度 | 简单 | 较复杂 |
|
||||
557
docs/modules/procgen/index.md
Normal file
557
docs/modules/procgen/index.md
Normal file
@@ -0,0 +1,557 @@
|
||||
# 程序化生成 (Procgen)
|
||||
|
||||
`@esengine/procgen` 提供了程序化内容生成的核心工具,包括噪声函数、种子随机数和各种随机工具。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install @esengine/procgen
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 噪声生成
|
||||
|
||||
```typescript
|
||||
import { createPerlinNoise, createFBM } from '@esengine/procgen';
|
||||
|
||||
// 创建 Perlin 噪声
|
||||
const perlin = createPerlinNoise(12345); // 种子
|
||||
|
||||
// 采样 2D 噪声
|
||||
const value = perlin.noise2D(x * 0.1, y * 0.1);
|
||||
console.log(value); // [-1, 1]
|
||||
|
||||
// 使用 FBM 获得更自然的效果
|
||||
const fbm = createFBM(perlin, {
|
||||
octaves: 6,
|
||||
persistence: 0.5
|
||||
});
|
||||
|
||||
const height = fbm.noise2D(x * 0.01, y * 0.01);
|
||||
```
|
||||
|
||||
### 种子随机数
|
||||
|
||||
```typescript
|
||||
import { createSeededRandom } from '@esengine/procgen';
|
||||
|
||||
// 创建确定性随机数生成器
|
||||
const rng = createSeededRandom(42);
|
||||
|
||||
// 相同种子总是产生相同序列
|
||||
console.log(rng.next()); // 0.xxx
|
||||
console.log(rng.nextInt(1, 100)); // 1-100
|
||||
console.log(rng.nextBool(0.3)); // 30% true
|
||||
```
|
||||
|
||||
### 加权随机
|
||||
|
||||
```typescript
|
||||
import { createWeightedRandom, createSeededRandom } from '@esengine/procgen';
|
||||
|
||||
const rng = createSeededRandom(42);
|
||||
|
||||
// 创建加权选择器
|
||||
const loot = createWeightedRandom([
|
||||
{ value: 'common', weight: 60 },
|
||||
{ value: 'uncommon', weight: 25 },
|
||||
{ value: 'rare', weight: 10 },
|
||||
{ value: 'legendary', weight: 5 }
|
||||
]);
|
||||
|
||||
// 随机选择
|
||||
const drop = loot.pick(rng);
|
||||
console.log(drop); // 大概率是 'common'
|
||||
```
|
||||
|
||||
## 噪声函数
|
||||
|
||||
### Perlin 噪声
|
||||
|
||||
经典的梯度噪声,输出范围 [-1, 1]:
|
||||
|
||||
```typescript
|
||||
import { createPerlinNoise } from '@esengine/procgen';
|
||||
|
||||
const perlin = createPerlinNoise(seed);
|
||||
|
||||
// 2D 噪声
|
||||
const value2D = perlin.noise2D(x, y);
|
||||
|
||||
// 3D 噪声
|
||||
const value3D = perlin.noise3D(x, y, z);
|
||||
```
|
||||
|
||||
### Simplex 噪声
|
||||
|
||||
比 Perlin 更快、更少方向性偏差:
|
||||
|
||||
```typescript
|
||||
import { createSimplexNoise } from '@esengine/procgen';
|
||||
|
||||
const simplex = createSimplexNoise(seed);
|
||||
|
||||
const value = simplex.noise2D(x, y);
|
||||
```
|
||||
|
||||
### Worley 噪声
|
||||
|
||||
基于细胞的噪声,适合生成石头、细胞等纹理:
|
||||
|
||||
```typescript
|
||||
import { createWorleyNoise } from '@esengine/procgen';
|
||||
|
||||
const worley = createWorleyNoise(seed);
|
||||
|
||||
// 返回到最近点的距离
|
||||
const distance = worley.noise2D(x, y);
|
||||
```
|
||||
|
||||
### FBM (分形布朗运动)
|
||||
|
||||
叠加多层噪声创建更丰富的细节:
|
||||
|
||||
```typescript
|
||||
import { createPerlinNoise, createFBM } from '@esengine/procgen';
|
||||
|
||||
const baseNoise = createPerlinNoise(seed);
|
||||
|
||||
const fbm = createFBM(baseNoise, {
|
||||
octaves: 6, // 层数(越多细节越丰富)
|
||||
lacunarity: 2.0, // 频率倍增因子
|
||||
persistence: 0.5, // 振幅衰减因子
|
||||
frequency: 1.0, // 初始频率
|
||||
amplitude: 1.0 // 初始振幅
|
||||
});
|
||||
|
||||
// 标准 FBM
|
||||
const value = fbm.noise2D(x, y);
|
||||
|
||||
// Ridged FBM(脊状,适合山脉)
|
||||
const ridged = fbm.ridged2D(x, y);
|
||||
|
||||
// Turbulence(湍流)
|
||||
const turb = fbm.turbulence2D(x, y);
|
||||
|
||||
// Billowed(膨胀,适合云朵)
|
||||
const cloud = fbm.billowed2D(x, y);
|
||||
```
|
||||
|
||||
## 种子随机数 API
|
||||
|
||||
### SeededRandom
|
||||
|
||||
基于 xorshift128+ 算法的确定性伪随机数生成器:
|
||||
|
||||
```typescript
|
||||
import { createSeededRandom } from '@esengine/procgen';
|
||||
|
||||
const rng = createSeededRandom(42);
|
||||
```
|
||||
|
||||
### 基础方法
|
||||
|
||||
```typescript
|
||||
// [0, 1) 浮点数
|
||||
rng.next();
|
||||
|
||||
// [min, max] 整数
|
||||
rng.nextInt(1, 10);
|
||||
|
||||
// [min, max) 浮点数
|
||||
rng.nextFloat(0, 100);
|
||||
|
||||
// 布尔值(可指定概率)
|
||||
rng.nextBool(); // 50%
|
||||
rng.nextBool(0.3); // 30%
|
||||
|
||||
// 重置到初始状态
|
||||
rng.reset();
|
||||
```
|
||||
|
||||
### 分布方法
|
||||
|
||||
```typescript
|
||||
// 正态分布(高斯分布)
|
||||
rng.nextGaussian(); // 均值 0, 标准差 1
|
||||
rng.nextGaussian(100, 15); // 均值 100, 标准差 15
|
||||
|
||||
// 指数分布
|
||||
rng.nextExponential(); // λ = 1
|
||||
rng.nextExponential(0.5); // λ = 0.5
|
||||
```
|
||||
|
||||
### 几何方法
|
||||
|
||||
```typescript
|
||||
// 圆内均匀分布的点
|
||||
const point = rng.nextPointInCircle(50); // { x, y }
|
||||
|
||||
// 圆周上的点
|
||||
const edge = rng.nextPointOnCircle(50); // { x, y }
|
||||
|
||||
// 球内均匀分布的点
|
||||
const point3D = rng.nextPointInSphere(50); // { x, y, z }
|
||||
|
||||
// 随机方向向量
|
||||
const dir = rng.nextDirection2D(); // { x, y },长度为 1
|
||||
```
|
||||
|
||||
## 加权随机 API
|
||||
|
||||
### WeightedRandom
|
||||
|
||||
预计算累积权重,高效随机选择:
|
||||
|
||||
```typescript
|
||||
import { createWeightedRandom } from '@esengine/procgen';
|
||||
|
||||
const selector = createWeightedRandom([
|
||||
{ value: 'apple', weight: 5 },
|
||||
{ value: 'banana', weight: 3 },
|
||||
{ value: 'cherry', weight: 2 }
|
||||
]);
|
||||
|
||||
// 使用种子随机数
|
||||
const result = selector.pick(rng);
|
||||
|
||||
// 使用 Math.random
|
||||
const result2 = selector.pickRandom();
|
||||
|
||||
// 获取概率
|
||||
console.log(selector.getProbability(0)); // 0.5 (5/10)
|
||||
console.log(selector.size); // 3
|
||||
console.log(selector.totalWeight); // 10
|
||||
```
|
||||
|
||||
### 便捷函数
|
||||
|
||||
```typescript
|
||||
import { weightedPick, weightedPickFromMap } from '@esengine/procgen';
|
||||
|
||||
// 从数组选择
|
||||
const item = weightedPick([
|
||||
{ value: 'a', weight: 1 },
|
||||
{ value: 'b', weight: 2 }
|
||||
], rng);
|
||||
|
||||
// 从对象选择
|
||||
const item2 = weightedPickFromMap({
|
||||
'common': 60,
|
||||
'rare': 30,
|
||||
'epic': 10
|
||||
}, rng);
|
||||
```
|
||||
|
||||
## 洗牌和采样 API
|
||||
|
||||
### shuffle / shuffleCopy
|
||||
|
||||
Fisher-Yates 洗牌算法:
|
||||
|
||||
```typescript
|
||||
import { shuffle, shuffleCopy } from '@esengine/procgen';
|
||||
|
||||
const arr = [1, 2, 3, 4, 5];
|
||||
|
||||
// 原地洗牌
|
||||
shuffle(arr, rng);
|
||||
|
||||
// 创建洗牌副本(不修改原数组)
|
||||
const shuffled = shuffleCopy(arr, rng);
|
||||
```
|
||||
|
||||
### pickOne
|
||||
|
||||
随机选择一个元素:
|
||||
|
||||
```typescript
|
||||
import { pickOne } from '@esengine/procgen';
|
||||
|
||||
const items = ['a', 'b', 'c', 'd'];
|
||||
const item = pickOne(items, rng);
|
||||
```
|
||||
|
||||
### sample / sampleWithReplacement
|
||||
|
||||
采样:
|
||||
|
||||
```typescript
|
||||
import { sample, sampleWithReplacement } from '@esengine/procgen';
|
||||
|
||||
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
|
||||
// 采样 3 个不重复元素
|
||||
const unique = sample(arr, 3, rng);
|
||||
|
||||
// 采样 5 个(可重复)
|
||||
const withRep = sampleWithReplacement(arr, 5, rng);
|
||||
```
|
||||
|
||||
### randomIntegers
|
||||
|
||||
生成范围内的随机整数数组:
|
||||
|
||||
```typescript
|
||||
import { randomIntegers } from '@esengine/procgen';
|
||||
|
||||
// 从 1-100 中随机选 5 个不重复的数
|
||||
const nums = randomIntegers(1, 100, 5, rng);
|
||||
```
|
||||
|
||||
### weightedSample
|
||||
|
||||
按权重采样(不重复):
|
||||
|
||||
```typescript
|
||||
import { weightedSample } from '@esengine/procgen';
|
||||
|
||||
const items = ['A', 'B', 'C', 'D', 'E'];
|
||||
const weights = [10, 8, 6, 4, 2];
|
||||
|
||||
// 按权重选 3 个
|
||||
const selected = weightedSample(items, weights, 3, rng);
|
||||
```
|
||||
|
||||
## 实际示例
|
||||
|
||||
### 程序化地形生成
|
||||
|
||||
```typescript
|
||||
import { createPerlinNoise, createFBM } from '@esengine/procgen';
|
||||
|
||||
class TerrainGenerator {
|
||||
private fbm: FBM;
|
||||
private moistureFbm: FBM;
|
||||
|
||||
constructor(seed: number) {
|
||||
const heightNoise = createPerlinNoise(seed);
|
||||
const moistureNoise = createPerlinNoise(seed + 1000);
|
||||
|
||||
this.fbm = createFBM(heightNoise, {
|
||||
octaves: 8,
|
||||
persistence: 0.5,
|
||||
frequency: 0.01
|
||||
});
|
||||
|
||||
this.moistureFbm = createFBM(moistureNoise, {
|
||||
octaves: 4,
|
||||
persistence: 0.6,
|
||||
frequency: 0.02
|
||||
});
|
||||
}
|
||||
|
||||
getHeight(x: number, y: number): number {
|
||||
// 基础高度
|
||||
let height = this.fbm.noise2D(x, y);
|
||||
|
||||
// 添加山脉
|
||||
height += this.fbm.ridged2D(x * 0.5, y * 0.5) * 0.3;
|
||||
|
||||
return (height + 1) * 0.5; // 归一化到 [0, 1]
|
||||
}
|
||||
|
||||
getBiome(x: number, y: number): string {
|
||||
const height = this.getHeight(x, y);
|
||||
const moisture = (this.moistureFbm.noise2D(x, y) + 1) * 0.5;
|
||||
|
||||
if (height < 0.3) return 'water';
|
||||
if (height < 0.4) return 'beach';
|
||||
if (height > 0.8) return 'mountain';
|
||||
|
||||
if (moisture < 0.3) return 'desert';
|
||||
if (moisture > 0.7) return 'forest';
|
||||
return 'grassland';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 战利品系统
|
||||
|
||||
```typescript
|
||||
import { createSeededRandom, createWeightedRandom, sample } from '@esengine/procgen';
|
||||
|
||||
interface LootItem {
|
||||
id: string;
|
||||
rarity: string;
|
||||
}
|
||||
|
||||
class LootSystem {
|
||||
private rng: SeededRandom;
|
||||
private raritySelector: WeightedRandom<string>;
|
||||
private lootTables: Map<string, LootItem[]> = new Map();
|
||||
|
||||
constructor(seed: number) {
|
||||
this.rng = createSeededRandom(seed);
|
||||
|
||||
this.raritySelector = createWeightedRandom([
|
||||
{ value: 'common', weight: 60 },
|
||||
{ value: 'uncommon', weight: 25 },
|
||||
{ value: 'rare', weight: 10 },
|
||||
{ value: 'legendary', weight: 5 }
|
||||
]);
|
||||
|
||||
// 初始化战利品表
|
||||
this.lootTables.set('common', [/* ... */]);
|
||||
this.lootTables.set('rare', [/* ... */]);
|
||||
// ...
|
||||
}
|
||||
|
||||
generateLoot(count: number): LootItem[] {
|
||||
const loot: LootItem[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const rarity = this.raritySelector.pick(this.rng);
|
||||
const table = this.lootTables.get(rarity)!;
|
||||
const item = pickOne(table, this.rng);
|
||||
loot.push(item);
|
||||
}
|
||||
|
||||
return loot;
|
||||
}
|
||||
|
||||
// 保证可重现
|
||||
setSeed(seed: number): void {
|
||||
this.rng = createSeededRandom(seed);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 程序化敌人放置
|
||||
|
||||
```typescript
|
||||
import { createSeededRandom } from '@esengine/procgen';
|
||||
|
||||
class EnemySpawner {
|
||||
private rng: SeededRandom;
|
||||
|
||||
constructor(seed: number) {
|
||||
this.rng = createSeededRandom(seed);
|
||||
}
|
||||
|
||||
spawnEnemiesInArea(
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
radius: number,
|
||||
count: number
|
||||
): Array<{ x: number; y: number; type: string }> {
|
||||
const enemies: Array<{ x: number; y: number; type: string }> = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// 在圆内生成位置
|
||||
const pos = this.rng.nextPointInCircle(radius);
|
||||
|
||||
// 随机选择敌人类型
|
||||
const type = this.rng.nextBool(0.2) ? 'elite' : 'normal';
|
||||
|
||||
enemies.push({
|
||||
x: centerX + pos.x,
|
||||
y: centerY + pos.y,
|
||||
type
|
||||
});
|
||||
}
|
||||
|
||||
return enemies;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 程序化关卡布局
|
||||
|
||||
```typescript
|
||||
import { createSeededRandom, shuffle } from '@esengine/procgen';
|
||||
|
||||
interface Room {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
type: 'start' | 'combat' | 'treasure' | 'boss';
|
||||
}
|
||||
|
||||
class DungeonGenerator {
|
||||
private rng: SeededRandom;
|
||||
|
||||
constructor(seed: number) {
|
||||
this.rng = createSeededRandom(seed);
|
||||
}
|
||||
|
||||
generate(roomCount: number): Room[] {
|
||||
const rooms: Room[] = [];
|
||||
|
||||
// 生成房间
|
||||
for (let i = 0; i < roomCount; i++) {
|
||||
rooms.push({
|
||||
x: this.rng.nextInt(0, 100),
|
||||
y: this.rng.nextInt(0, 100),
|
||||
width: this.rng.nextInt(5, 15),
|
||||
height: this.rng.nextInt(5, 15),
|
||||
type: 'combat'
|
||||
});
|
||||
}
|
||||
|
||||
// 随机分配特殊房间
|
||||
shuffle(rooms, this.rng);
|
||||
rooms[0].type = 'start';
|
||||
rooms[1].type = 'treasure';
|
||||
rooms[rooms.length - 1].type = 'boss';
|
||||
|
||||
return rooms;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 蓝图节点
|
||||
|
||||
Procgen 模块提供了可视化脚本支持的蓝图节点:
|
||||
|
||||
### 噪声节点
|
||||
|
||||
- `SampleNoise2D` - 采样 2D 噪声
|
||||
- `SampleFBM` - 采样 FBM 噪声
|
||||
|
||||
### 随机节点
|
||||
|
||||
- `SeededRandom` - 生成随机浮点数
|
||||
- `SeededRandomInt` - 生成随机整数
|
||||
- `WeightedPick` - 加权随机选择
|
||||
- `ShuffleArray` - 洗牌数组
|
||||
- `PickRandom` - 随机选择元素
|
||||
- `SampleArray` - 采样数组
|
||||
- `RandomPointInCircle` - 圆内随机点
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用种子保证可重现性**
|
||||
```typescript
|
||||
// 保存种子以便重现相同结果
|
||||
const seed = Date.now();
|
||||
const rng = createSeededRandom(seed);
|
||||
saveSeed(seed);
|
||||
```
|
||||
|
||||
2. **预计算加权选择器**
|
||||
```typescript
|
||||
// 好:创建一次,多次使用
|
||||
const selector = createWeightedRandom(items);
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
selector.pick(rng);
|
||||
}
|
||||
|
||||
// 不好:每次都创建
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
weightedPick(items, rng);
|
||||
}
|
||||
```
|
||||
|
||||
3. **选择合适的噪声函数**
|
||||
- Perlin:平滑过渡的地形、云彩
|
||||
- Simplex:性能要求高的场景
|
||||
- Worley:细胞、石头纹理
|
||||
- FBM:需要多层细节的自然效果
|
||||
|
||||
4. **调整 FBM 参数**
|
||||
- `octaves`:越多细节越丰富,但性能开销越大
|
||||
- `persistence`:0.5 是常用值,越大高频细节越明显
|
||||
- `lacunarity`:通常为 2,控制频率增长速度
|
||||
600
docs/modules/spatial/index.md
Normal file
600
docs/modules/spatial/index.md
Normal file
@@ -0,0 +1,600 @@
|
||||
# 空间索引系统 (Spatial)
|
||||
|
||||
`@esengine/spatial` 提供了高效的空间查询和索引功能,包括范围查询、最近邻查询、射线检测和 AOI(兴趣区域)管理。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install @esengine/spatial
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 空间索引
|
||||
|
||||
```typescript
|
||||
import { createGridSpatialIndex } from '@esengine/spatial';
|
||||
|
||||
// 创建空间索引(网格单元格大小为 100)
|
||||
const spatialIndex = createGridSpatialIndex<Entity>(100);
|
||||
|
||||
// 插入对象
|
||||
spatialIndex.insert(player, { x: 100, y: 200 });
|
||||
spatialIndex.insert(enemy1, { x: 150, y: 250 });
|
||||
spatialIndex.insert(enemy2, { x: 500, y: 600 });
|
||||
|
||||
// 查找半径内的对象
|
||||
const nearby = spatialIndex.findInRadius({ x: 100, y: 200 }, 100);
|
||||
console.log(nearby); // [player, enemy1]
|
||||
|
||||
// 查找最近的对象
|
||||
const nearest = spatialIndex.findNearest({ x: 100, y: 200 });
|
||||
console.log(nearest); // enemy1
|
||||
|
||||
// 更新位置
|
||||
spatialIndex.update(player, { x: 120, y: 220 });
|
||||
```
|
||||
|
||||
### AOI 兴趣区域
|
||||
|
||||
```typescript
|
||||
import { createGridAOI } from '@esengine/spatial';
|
||||
|
||||
// 创建 AOI 管理器
|
||||
const aoi = createGridAOI<Entity>(100);
|
||||
|
||||
// 添加观察者(玩家)
|
||||
aoi.addObserver(player, { x: 100, y: 100 }, { viewRange: 200 });
|
||||
aoi.addObserver(npc, { x: 150, y: 150 }, { viewRange: 150 });
|
||||
|
||||
// 监听进入/离开事件
|
||||
aoi.addListener((event) => {
|
||||
if (event.type === 'enter') {
|
||||
console.log(`${event.observer} 看到了 ${event.target}`);
|
||||
} else if (event.type === 'exit') {
|
||||
console.log(`${event.target} 离开了 ${event.observer} 的视野`);
|
||||
}
|
||||
});
|
||||
|
||||
// 更新位置(会自动触发进入/离开事件)
|
||||
aoi.updatePosition(player, { x: 200, y: 200 });
|
||||
|
||||
// 获取视野内的实体
|
||||
const visible = aoi.getEntitiesInView(player);
|
||||
```
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 空间索引 vs AOI
|
||||
|
||||
| 特性 | 空间索引 (SpatialIndex) | AOI (Area of Interest) |
|
||||
|------|------------------------|------------------------|
|
||||
| 用途 | 通用空间查询 | 实体可见性追踪 |
|
||||
| 事件 | 无事件通知 | 进入/离开事件 |
|
||||
| 方向 | 单向查询 | 双向追踪(谁看到谁) |
|
||||
| 场景 | 碰撞检测、范围攻击 | MMO 同步、NPC AI 感知 |
|
||||
|
||||
### IBounds 边界框
|
||||
|
||||
```typescript
|
||||
interface IBounds {
|
||||
readonly minX: number;
|
||||
readonly minY: number;
|
||||
readonly maxX: number;
|
||||
readonly maxY: number;
|
||||
}
|
||||
```
|
||||
|
||||
### IRaycastHit 射线检测结果
|
||||
|
||||
```typescript
|
||||
interface IRaycastHit<T> {
|
||||
readonly target: T; // 命中的对象
|
||||
readonly point: IVector2; // 命中点坐标
|
||||
readonly normal: IVector2; // 命中点法线
|
||||
readonly distance: number; // 距离射线起点的距离
|
||||
}
|
||||
```
|
||||
|
||||
## 空间索引 API
|
||||
|
||||
### createGridSpatialIndex
|
||||
|
||||
```typescript
|
||||
function createGridSpatialIndex<T>(cellSize?: number): GridSpatialIndex<T>
|
||||
```
|
||||
|
||||
创建基于均匀网格的空间索引。
|
||||
|
||||
**参数:**
|
||||
- `cellSize` - 网格单元格大小(默认 100)
|
||||
|
||||
**选择合适的 cellSize:**
|
||||
- 太小:内存占用高,查询效率降低
|
||||
- 太大:单元格内对象过多,遍历耗时
|
||||
- 建议:设置为对象平均分布间距的 1-2 倍
|
||||
|
||||
### 管理方法
|
||||
|
||||
#### insert
|
||||
|
||||
插入对象到索引:
|
||||
|
||||
```typescript
|
||||
spatialIndex.insert(enemy, { x: 100, y: 200 });
|
||||
```
|
||||
|
||||
#### remove
|
||||
|
||||
移除对象:
|
||||
|
||||
```typescript
|
||||
spatialIndex.remove(enemy);
|
||||
```
|
||||
|
||||
#### update
|
||||
|
||||
更新对象位置:
|
||||
|
||||
```typescript
|
||||
spatialIndex.update(enemy, { x: 150, y: 250 });
|
||||
```
|
||||
|
||||
#### clear
|
||||
|
||||
清空索引:
|
||||
|
||||
```typescript
|
||||
spatialIndex.clear();
|
||||
```
|
||||
|
||||
### 查询方法
|
||||
|
||||
#### findInRadius
|
||||
|
||||
查找圆形范围内的所有对象:
|
||||
|
||||
```typescript
|
||||
// 查找中心点 (100, 200) 半径 50 内的所有敌人
|
||||
const enemies = spatialIndex.findInRadius(
|
||||
{ x: 100, y: 200 },
|
||||
50,
|
||||
(entity) => entity.type === 'enemy' // 可选过滤器
|
||||
);
|
||||
```
|
||||
|
||||
#### findInRect
|
||||
|
||||
查找矩形区域内的所有对象:
|
||||
|
||||
```typescript
|
||||
import { createBounds } from '@esengine/spatial';
|
||||
|
||||
const bounds = createBounds(0, 0, 200, 200);
|
||||
const entities = spatialIndex.findInRect(bounds);
|
||||
```
|
||||
|
||||
#### findNearest
|
||||
|
||||
查找最近的对象:
|
||||
|
||||
```typescript
|
||||
// 查找最近的敌人(最大搜索距离 500)
|
||||
const nearest = spatialIndex.findNearest(
|
||||
playerPosition,
|
||||
500, // maxDistance
|
||||
(entity) => entity.type === 'enemy'
|
||||
);
|
||||
|
||||
if (nearest) {
|
||||
attackTarget(nearest);
|
||||
}
|
||||
```
|
||||
|
||||
#### findKNearest
|
||||
|
||||
查找最近的 K 个对象:
|
||||
|
||||
```typescript
|
||||
// 查找最近的 5 个敌人
|
||||
const nearestEnemies = spatialIndex.findKNearest(
|
||||
playerPosition,
|
||||
5, // k
|
||||
500, // maxDistance
|
||||
(entity) => entity.type === 'enemy'
|
||||
);
|
||||
```
|
||||
|
||||
#### raycast
|
||||
|
||||
射线检测(返回所有命中):
|
||||
|
||||
```typescript
|
||||
const hits = spatialIndex.raycast(
|
||||
origin, // 射线起点
|
||||
direction, // 射线方向(应归一化)
|
||||
maxDistance, // 最大检测距离
|
||||
filter // 可选过滤器
|
||||
);
|
||||
|
||||
// hits 按距离排序
|
||||
for (const hit of hits) {
|
||||
console.log(`命中 ${hit.target} at ${hit.point}, 距离 ${hit.distance}`);
|
||||
}
|
||||
```
|
||||
|
||||
#### raycastFirst
|
||||
|
||||
射线检测(仅返回第一个命中):
|
||||
|
||||
```typescript
|
||||
const hit = spatialIndex.raycastFirst(origin, direction, 1000);
|
||||
if (hit) {
|
||||
dealDamage(hit.target, calculateDamage(hit.distance));
|
||||
}
|
||||
```
|
||||
|
||||
### 属性
|
||||
|
||||
```typescript
|
||||
// 获取索引中的对象数量
|
||||
console.log(spatialIndex.count);
|
||||
|
||||
// 获取所有对象
|
||||
const all = spatialIndex.getAll();
|
||||
```
|
||||
|
||||
## AOI 兴趣区域 API
|
||||
|
||||
### createGridAOI
|
||||
|
||||
```typescript
|
||||
function createGridAOI<T>(cellSize?: number): GridAOI<T>
|
||||
```
|
||||
|
||||
创建基于网格的 AOI 管理器。
|
||||
|
||||
**参数:**
|
||||
- `cellSize` - 网格单元格大小(建议为平均视野范围的 1-2 倍)
|
||||
|
||||
### 观察者管理
|
||||
|
||||
#### addObserver
|
||||
|
||||
添加观察者:
|
||||
|
||||
```typescript
|
||||
aoi.addObserver(player, position, {
|
||||
viewRange: 200, // 视野范围
|
||||
observable: true // 是否可被其他观察者看到(默认 true)
|
||||
});
|
||||
|
||||
// NPC 只观察不被观察
|
||||
aoi.addObserver(camera, position, {
|
||||
viewRange: 500,
|
||||
observable: false
|
||||
});
|
||||
```
|
||||
|
||||
#### removeObserver
|
||||
|
||||
移除观察者:
|
||||
|
||||
```typescript
|
||||
aoi.removeObserver(player);
|
||||
```
|
||||
|
||||
#### updatePosition
|
||||
|
||||
更新位置(自动触发进入/离开事件):
|
||||
|
||||
```typescript
|
||||
aoi.updatePosition(player, newPosition);
|
||||
```
|
||||
|
||||
#### updateViewRange
|
||||
|
||||
更新视野范围:
|
||||
|
||||
```typescript
|
||||
// 获得增益后视野扩大
|
||||
aoi.updateViewRange(player, 300);
|
||||
```
|
||||
|
||||
### 查询方法
|
||||
|
||||
#### getEntitiesInView
|
||||
|
||||
获取观察者视野内的所有实体:
|
||||
|
||||
```typescript
|
||||
const visible = aoi.getEntitiesInView(player);
|
||||
for (const entity of visible) {
|
||||
updateEntityForPlayer(player, entity);
|
||||
}
|
||||
```
|
||||
|
||||
#### getObserversOf
|
||||
|
||||
获取能看到指定实体的所有观察者:
|
||||
|
||||
```typescript
|
||||
const observers = aoi.getObserversOf(monster);
|
||||
for (const observer of observers) {
|
||||
notifyMonsterMoved(observer, monster);
|
||||
}
|
||||
```
|
||||
|
||||
#### canSee
|
||||
|
||||
检查是否可见:
|
||||
|
||||
```typescript
|
||||
if (aoi.canSee(player, enemy)) {
|
||||
enemy.showHealthBar();
|
||||
}
|
||||
```
|
||||
|
||||
### 事件系统
|
||||
|
||||
#### 全局事件监听
|
||||
|
||||
```typescript
|
||||
aoi.addListener((event) => {
|
||||
switch (event.type) {
|
||||
case 'enter':
|
||||
console.log(`${event.observer} 看到了 ${event.target}`);
|
||||
break;
|
||||
case 'exit':
|
||||
console.log(`${event.target} 离开了 ${event.observer} 的视野`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### 实体特定事件监听
|
||||
|
||||
```typescript
|
||||
// 只监听特定玩家的视野事件
|
||||
aoi.addEntityListener(player, (event) => {
|
||||
if (event.type === 'enter') {
|
||||
sendToClient(player, 'entity_enter', event.target);
|
||||
} else if (event.type === 'exit') {
|
||||
sendToClient(player, 'entity_exit', event.target);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### 事件类型
|
||||
|
||||
```typescript
|
||||
interface IAOIEvent<T> {
|
||||
type: 'enter' | 'exit' | 'update';
|
||||
observer: T; // 观察者(谁看到了变化)
|
||||
target: T; // 目标(发生变化的对象)
|
||||
position: IVector2; // 目标位置
|
||||
}
|
||||
```
|
||||
|
||||
## 工具函数
|
||||
|
||||
### 边界框创建
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createBounds,
|
||||
createBoundsFromCenter,
|
||||
createBoundsFromCircle
|
||||
} from '@esengine/spatial';
|
||||
|
||||
// 从角点创建
|
||||
const bounds1 = createBounds(0, 0, 100, 100);
|
||||
|
||||
// 从中心点和尺寸创建
|
||||
const bounds2 = createBoundsFromCenter({ x: 50, y: 50 }, 100, 100);
|
||||
|
||||
// 从圆形创建(包围盒)
|
||||
const bounds3 = createBoundsFromCircle({ x: 50, y: 50 }, 50);
|
||||
```
|
||||
|
||||
### 几何检测
|
||||
|
||||
```typescript
|
||||
import {
|
||||
isPointInBounds,
|
||||
boundsIntersect,
|
||||
boundsIntersectsCircle,
|
||||
distance,
|
||||
distanceSquared
|
||||
} from '@esengine/spatial';
|
||||
|
||||
// 点在边界内?
|
||||
if (isPointInBounds(point, bounds)) { ... }
|
||||
|
||||
// 两个边界框相交?
|
||||
if (boundsIntersect(boundsA, boundsB)) { ... }
|
||||
|
||||
// 边界框与圆形相交?
|
||||
if (boundsIntersectsCircle(bounds, center, radius)) { ... }
|
||||
|
||||
// 距离计算
|
||||
const dist = distance(pointA, pointB);
|
||||
const distSq = distanceSquared(pointA, pointB); // 更快,避免 sqrt
|
||||
```
|
||||
|
||||
## 实际示例
|
||||
|
||||
### 范围攻击检测
|
||||
|
||||
```typescript
|
||||
class CombatSystem {
|
||||
private spatialIndex: ISpatialIndex<Entity>;
|
||||
|
||||
dealAreaDamage(center: IVector2, radius: number, damage: number): void {
|
||||
const targets = this.spatialIndex.findInRadius(
|
||||
center,
|
||||
radius,
|
||||
(entity) => entity.hasComponent(HealthComponent)
|
||||
);
|
||||
|
||||
for (const target of targets) {
|
||||
const health = target.getComponent(HealthComponent);
|
||||
health.takeDamage(damage);
|
||||
}
|
||||
}
|
||||
|
||||
findNearestEnemy(position: IVector2, team: string): Entity | null {
|
||||
return this.spatialIndex.findNearest(
|
||||
position,
|
||||
undefined, // 无距离限制
|
||||
(entity) => {
|
||||
const teamComp = entity.getComponent(TeamComponent);
|
||||
return teamComp && teamComp.team !== team;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MMO 同步系统
|
||||
|
||||
```typescript
|
||||
class SyncSystem {
|
||||
private aoi: IAOIManager<Player>;
|
||||
|
||||
constructor() {
|
||||
this.aoi = createGridAOI<Player>(100);
|
||||
|
||||
// 监听进入/离开事件
|
||||
this.aoi.addListener((event) => {
|
||||
const packet = this.createSyncPacket(event);
|
||||
this.sendToPlayer(event.observer, packet);
|
||||
});
|
||||
}
|
||||
|
||||
onPlayerJoin(player: Player): void {
|
||||
this.aoi.addObserver(player, player.position, {
|
||||
viewRange: player.viewRange
|
||||
});
|
||||
}
|
||||
|
||||
onPlayerMove(player: Player, newPosition: IVector2): void {
|
||||
this.aoi.updatePosition(player, newPosition);
|
||||
}
|
||||
|
||||
onPlayerLeave(player: Player): void {
|
||||
this.aoi.removeObserver(player);
|
||||
}
|
||||
|
||||
// 广播给所有能看到某玩家的其他玩家
|
||||
broadcastToObservers(player: Player, packet: Packet): void {
|
||||
const observers = this.aoi.getObserversOf(player);
|
||||
for (const observer of observers) {
|
||||
this.sendToPlayer(observer, packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### NPC AI 感知
|
||||
|
||||
```typescript
|
||||
class AIPerceptionSystem {
|
||||
private aoi: IAOIManager<Entity>;
|
||||
|
||||
constructor() {
|
||||
this.aoi = createGridAOI<Entity>(50);
|
||||
}
|
||||
|
||||
setupNPC(npc: Entity): void {
|
||||
const perception = npc.getComponent(PerceptionComponent);
|
||||
|
||||
this.aoi.addObserver(npc, npc.position, {
|
||||
viewRange: perception.range
|
||||
});
|
||||
|
||||
// 监听该 NPC 的感知事件
|
||||
this.aoi.addEntityListener(npc, (event) => {
|
||||
const ai = npc.getComponent(AIComponent);
|
||||
|
||||
if (event.type === 'enter') {
|
||||
ai.onTargetDetected(event.target);
|
||||
} else if (event.type === 'exit') {
|
||||
ai.onTargetLost(event.target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
update(): void {
|
||||
// 更新所有 NPC 位置
|
||||
for (const npc of this.npcs) {
|
||||
this.aoi.updatePosition(npc, npc.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 蓝图节点
|
||||
|
||||
### 空间查询节点
|
||||
|
||||
- `FindInRadius` - 查找半径内的对象
|
||||
- `FindInRect` - 查找矩形内的对象
|
||||
- `FindNearest` - 查找最近的对象
|
||||
- `FindKNearest` - 查找最近的 K 个对象
|
||||
- `Raycast` - 射线检测
|
||||
- `RaycastFirst` - 射线检测(仅第一个)
|
||||
|
||||
### AOI 节点
|
||||
|
||||
- `GetEntitiesInView` - 获取视野内实体
|
||||
- `GetObserversOf` - 获取观察者
|
||||
- `CanSee` - 检查可见性
|
||||
- `OnEntityEnterView` - 进入视野事件
|
||||
- `OnEntityExitView` - 离开视野事件
|
||||
|
||||
## 服务令牌
|
||||
|
||||
在依赖注入场景中使用:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
SpatialIndexToken,
|
||||
SpatialQueryToken,
|
||||
AOIManagerToken,
|
||||
createGridSpatialIndex,
|
||||
createGridAOI
|
||||
} from '@esengine/spatial';
|
||||
|
||||
// 注册服务
|
||||
services.register(SpatialIndexToken, createGridSpatialIndex(100));
|
||||
services.register(AOIManagerToken, createGridAOI(100));
|
||||
|
||||
// 获取服务
|
||||
const spatialIndex = services.get(SpatialIndexToken);
|
||||
const aoiManager = services.get(AOIManagerToken);
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
1. **选择合适的 cellSize**
|
||||
- 太小:内存占用高,单元格数量多
|
||||
- 太大:单元格内对象多,遍历慢
|
||||
- 经验法则:对象平均间距的 1-2 倍
|
||||
|
||||
2. **使用过滤器减少结果**
|
||||
```typescript
|
||||
// 在空间查询阶段就过滤,而不是事后过滤
|
||||
spatialIndex.findInRadius(center, radius, (e) => e.type === 'enemy');
|
||||
```
|
||||
|
||||
3. **使用 distanceSquared 代替 distance**
|
||||
```typescript
|
||||
// 避免 sqrt 计算
|
||||
if (distanceSquared(a, b) < threshold * threshold) { ... }
|
||||
```
|
||||
|
||||
4. **批量更新优化**
|
||||
```typescript
|
||||
// 如果有大量对象同时移动,考虑禁用事件后批量更新
|
||||
```
|
||||
479
docs/modules/timer/index.md
Normal file
479
docs/modules/timer/index.md
Normal file
@@ -0,0 +1,479 @@
|
||||
# 定时器系统 (Timer)
|
||||
|
||||
`@esengine/timer` 提供了一个灵活的定时器和冷却系统,用于游戏中的延迟执行、重复任务、技能冷却等场景。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install @esengine/timer
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```typescript
|
||||
import { createTimerService } from '@esengine/timer';
|
||||
|
||||
// 创建定时器服务
|
||||
const timerService = createTimerService();
|
||||
|
||||
// 一次性定时器(1秒后执行)
|
||||
const handle = timerService.schedule('myTimer', 1000, () => {
|
||||
console.log('Timer fired!');
|
||||
});
|
||||
|
||||
// 重复定时器(每100毫秒执行)
|
||||
timerService.scheduleRepeating('heartbeat', 100, () => {
|
||||
console.log('Tick');
|
||||
});
|
||||
|
||||
// 冷却系统(5秒冷却)
|
||||
timerService.startCooldown('skill_fireball', 5000);
|
||||
|
||||
if (timerService.isCooldownReady('skill_fireball')) {
|
||||
// 可以使用技能
|
||||
useFireball();
|
||||
timerService.startCooldown('skill_fireball', 5000);
|
||||
}
|
||||
|
||||
// 游戏循环中更新
|
||||
function gameLoop(deltaTime: number) {
|
||||
timerService.update(deltaTime);
|
||||
}
|
||||
```
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 定时器 vs 冷却
|
||||
|
||||
| 特性 | 定时器 (Timer) | 冷却 (Cooldown) |
|
||||
|------|---------------|-----------------|
|
||||
| 用途 | 延迟执行代码 | 限制操作频率 |
|
||||
| 回调 | 有回调函数 | 无回调函数 |
|
||||
| 重复 | 支持重复执行 | 一次性 |
|
||||
| 查询 | 查询剩余时间 | 查询进度/是否就绪 |
|
||||
|
||||
### TimerHandle
|
||||
|
||||
调度定时器后返回的句柄对象,用于控制定时器:
|
||||
|
||||
```typescript
|
||||
interface TimerHandle {
|
||||
readonly id: string; // 定时器 ID
|
||||
readonly isValid: boolean; // 是否有效(未被取消)
|
||||
cancel(): void; // 取消定时器
|
||||
}
|
||||
```
|
||||
|
||||
### TimerInfo
|
||||
|
||||
定时器信息对象:
|
||||
|
||||
```typescript
|
||||
interface TimerInfo {
|
||||
readonly id: string; // 定时器 ID
|
||||
readonly remaining: number; // 剩余时间(毫秒)
|
||||
readonly repeating: boolean; // 是否重复执行
|
||||
readonly interval?: number; // 间隔时间(仅重复定时器)
|
||||
}
|
||||
```
|
||||
|
||||
### CooldownInfo
|
||||
|
||||
冷却信息对象:
|
||||
|
||||
```typescript
|
||||
interface CooldownInfo {
|
||||
readonly id: string; // 冷却 ID
|
||||
readonly duration: number; // 总持续时间(毫秒)
|
||||
readonly remaining: number; // 剩余时间(毫秒)
|
||||
readonly progress: number; // 进度(0-1,0=刚开始,1=结束)
|
||||
readonly isReady: boolean; // 是否已就绪
|
||||
}
|
||||
```
|
||||
|
||||
## API 参考
|
||||
|
||||
### createTimerService
|
||||
|
||||
```typescript
|
||||
function createTimerService(config?: TimerServiceConfig): ITimerService
|
||||
```
|
||||
|
||||
**配置选项:**
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `maxTimers` | `number` | `0` | 最大定时器数量(0 表示无限制) |
|
||||
| `maxCooldowns` | `number` | `0` | 最大冷却数量(0 表示无限制) |
|
||||
|
||||
### 定时器 API
|
||||
|
||||
#### schedule
|
||||
|
||||
调度一次性定时器:
|
||||
|
||||
```typescript
|
||||
const handle = timerService.schedule('explosion', 2000, () => {
|
||||
createExplosion();
|
||||
});
|
||||
|
||||
// 提前取消
|
||||
handle.cancel();
|
||||
```
|
||||
|
||||
#### scheduleRepeating
|
||||
|
||||
调度重复定时器:
|
||||
|
||||
```typescript
|
||||
// 每秒执行
|
||||
timerService.scheduleRepeating('regen', 1000, () => {
|
||||
player.hp += 5;
|
||||
});
|
||||
|
||||
// 立即执行一次,然后每秒重复
|
||||
timerService.scheduleRepeating('tick', 1000, () => {
|
||||
console.log('Tick');
|
||||
}, true); // immediate = true
|
||||
```
|
||||
|
||||
#### cancel / cancelById
|
||||
|
||||
取消定时器:
|
||||
|
||||
```typescript
|
||||
// 通过句柄取消
|
||||
handle.cancel();
|
||||
// 或
|
||||
timerService.cancel(handle);
|
||||
|
||||
// 通过 ID 取消
|
||||
timerService.cancelById('regen');
|
||||
```
|
||||
|
||||
#### hasTimer
|
||||
|
||||
检查定时器是否存在:
|
||||
|
||||
```typescript
|
||||
if (timerService.hasTimer('explosion')) {
|
||||
console.log('Explosion is pending');
|
||||
}
|
||||
```
|
||||
|
||||
#### getTimerInfo
|
||||
|
||||
获取定时器信息:
|
||||
|
||||
```typescript
|
||||
const info = timerService.getTimerInfo('explosion');
|
||||
if (info) {
|
||||
console.log(`剩余时间: ${info.remaining}ms`);
|
||||
console.log(`是否重复: ${info.repeating}`);
|
||||
}
|
||||
```
|
||||
|
||||
### 冷却 API
|
||||
|
||||
#### startCooldown
|
||||
|
||||
开始冷却:
|
||||
|
||||
```typescript
|
||||
// 5秒冷却
|
||||
timerService.startCooldown('skill_fireball', 5000);
|
||||
```
|
||||
|
||||
#### isCooldownReady / isOnCooldown
|
||||
|
||||
检查冷却状态:
|
||||
|
||||
```typescript
|
||||
if (timerService.isCooldownReady('skill_fireball')) {
|
||||
// 可以使用技能
|
||||
castFireball();
|
||||
timerService.startCooldown('skill_fireball', 5000);
|
||||
} else {
|
||||
console.log('技能还在冷却中');
|
||||
}
|
||||
|
||||
// 或使用 isOnCooldown
|
||||
if (timerService.isOnCooldown('skill_fireball')) {
|
||||
console.log('冷却中...');
|
||||
}
|
||||
```
|
||||
|
||||
#### getCooldownProgress / getCooldownRemaining
|
||||
|
||||
获取冷却进度:
|
||||
|
||||
```typescript
|
||||
// 进度 0-1(0=刚开始,1=完成)
|
||||
const progress = timerService.getCooldownProgress('skill_fireball');
|
||||
console.log(`冷却进度: ${(progress * 100).toFixed(0)}%`);
|
||||
|
||||
// 剩余时间(毫秒)
|
||||
const remaining = timerService.getCooldownRemaining('skill_fireball');
|
||||
console.log(`剩余时间: ${(remaining / 1000).toFixed(1)}s`);
|
||||
```
|
||||
|
||||
#### getCooldownInfo
|
||||
|
||||
获取完整冷却信息:
|
||||
|
||||
```typescript
|
||||
const info = timerService.getCooldownInfo('skill_fireball');
|
||||
if (info) {
|
||||
console.log(`总时长: ${info.duration}ms`);
|
||||
console.log(`剩余: ${info.remaining}ms`);
|
||||
console.log(`进度: ${info.progress}`);
|
||||
console.log(`就绪: ${info.isReady}`);
|
||||
}
|
||||
```
|
||||
|
||||
#### resetCooldown / clearAllCooldowns
|
||||
|
||||
重置冷却:
|
||||
|
||||
```typescript
|
||||
// 重置单个冷却
|
||||
timerService.resetCooldown('skill_fireball');
|
||||
|
||||
// 清除所有冷却(例如角色复活时)
|
||||
timerService.clearAllCooldowns();
|
||||
```
|
||||
|
||||
### 生命周期
|
||||
|
||||
#### update
|
||||
|
||||
更新定时器服务(需要每帧调用):
|
||||
|
||||
```typescript
|
||||
function gameLoop(deltaTime: number) {
|
||||
// deltaTime 单位是毫秒
|
||||
timerService.update(deltaTime);
|
||||
}
|
||||
```
|
||||
|
||||
#### clear
|
||||
|
||||
清除所有定时器和冷却:
|
||||
|
||||
```typescript
|
||||
timerService.clear();
|
||||
```
|
||||
|
||||
### 调试属性
|
||||
|
||||
```typescript
|
||||
// 获取活跃定时器数量
|
||||
console.log(timerService.activeTimerCount);
|
||||
|
||||
// 获取活跃冷却数量
|
||||
console.log(timerService.activeCooldownCount);
|
||||
|
||||
// 获取所有活跃定时器 ID
|
||||
const timerIds = timerService.getActiveTimerIds();
|
||||
|
||||
// 获取所有活跃冷却 ID
|
||||
const cooldownIds = timerService.getActiveCooldownIds();
|
||||
```
|
||||
|
||||
## 实际示例
|
||||
|
||||
### 技能冷却系统
|
||||
|
||||
```typescript
|
||||
import { createTimerService, type ITimerService } from '@esengine/timer';
|
||||
|
||||
class SkillSystem {
|
||||
private timerService: ITimerService;
|
||||
private skills: Map<string, SkillData> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.timerService = createTimerService();
|
||||
}
|
||||
|
||||
registerSkill(id: string, data: SkillData): void {
|
||||
this.skills.set(id, data);
|
||||
}
|
||||
|
||||
useSkill(skillId: string): boolean {
|
||||
const skill = this.skills.get(skillId);
|
||||
if (!skill) return false;
|
||||
|
||||
// 检查冷却
|
||||
if (!this.timerService.isCooldownReady(skillId)) {
|
||||
const remaining = this.timerService.getCooldownRemaining(skillId);
|
||||
console.log(`技能 ${skillId} 冷却中,剩余 ${remaining}ms`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 使用技能
|
||||
this.executeSkill(skill);
|
||||
|
||||
// 开始冷却
|
||||
this.timerService.startCooldown(skillId, skill.cooldown);
|
||||
return true;
|
||||
}
|
||||
|
||||
getSkillCooldownProgress(skillId: string): number {
|
||||
return this.timerService.getCooldownProgress(skillId);
|
||||
}
|
||||
|
||||
update(dt: number): void {
|
||||
this.timerService.update(dt);
|
||||
}
|
||||
}
|
||||
|
||||
interface SkillData {
|
||||
cooldown: number;
|
||||
// ... other properties
|
||||
}
|
||||
```
|
||||
|
||||
### 延迟和定时效果
|
||||
|
||||
```typescript
|
||||
class EffectSystem {
|
||||
private timerService: ITimerService;
|
||||
|
||||
constructor(timerService: ITimerService) {
|
||||
this.timerService = timerService;
|
||||
}
|
||||
|
||||
// 延迟爆炸
|
||||
scheduleExplosion(position: { x: number; y: number }, delay: number): void {
|
||||
this.timerService.schedule(`explosion_${Date.now()}`, delay, () => {
|
||||
this.createExplosion(position);
|
||||
});
|
||||
}
|
||||
|
||||
// DOT 伤害(每秒造成伤害)
|
||||
applyDOT(target: Entity, damage: number, duration: number): void {
|
||||
const dotId = `dot_${target.id}_${Date.now()}`;
|
||||
let elapsed = 0;
|
||||
|
||||
this.timerService.scheduleRepeating(dotId, 1000, () => {
|
||||
elapsed += 1000;
|
||||
target.takeDamage(damage);
|
||||
|
||||
if (elapsed >= duration) {
|
||||
this.timerService.cancelById(dotId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// BUFF 效果(持续一段时间)
|
||||
applyBuff(target: Entity, buffId: string, duration: number): void {
|
||||
target.addBuff(buffId);
|
||||
|
||||
this.timerService.schedule(`buff_expire_${buffId}`, duration, () => {
|
||||
target.removeBuff(buffId);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 与 ECS 集成
|
||||
|
||||
```typescript
|
||||
import { Component, EntitySystem, Matcher } from '@esengine/ecs-framework';
|
||||
import { createTimerService, type ITimerService } from '@esengine/timer';
|
||||
|
||||
// 定时器组件
|
||||
class TimerComponent extends Component {
|
||||
timerService: ITimerService;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.timerService = createTimerService();
|
||||
}
|
||||
}
|
||||
|
||||
// 定时器系统
|
||||
class TimerSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.all(TimerComponent));
|
||||
}
|
||||
|
||||
protected processEntity(entity: Entity, dt: number): void {
|
||||
const timer = entity.getComponent(TimerComponent);
|
||||
timer.timerService.update(dt);
|
||||
}
|
||||
}
|
||||
|
||||
// 冷却组件(用于共享冷却)
|
||||
class CooldownComponent extends Component {
|
||||
constructor(public timerService: ITimerService) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 蓝图节点
|
||||
|
||||
Timer 模块提供了可视化脚本支持的蓝图节点:
|
||||
|
||||
### 冷却节点
|
||||
|
||||
- `StartCooldown` - 开始冷却
|
||||
- `IsCooldownReady` - 检查冷却是否就绪
|
||||
- `GetCooldownProgress` - 获取冷却进度
|
||||
- `GetCooldownInfo` - 获取详细冷却信息
|
||||
- `ResetCooldown` - 重置冷却
|
||||
|
||||
### 定时器节点
|
||||
|
||||
- `HasTimer` - 检查定时器是否存在
|
||||
- `CancelTimer` - 取消定时器
|
||||
- `GetTimerRemaining` - 获取定时器剩余时间
|
||||
|
||||
## 服务令牌
|
||||
|
||||
在依赖注入场景中使用:
|
||||
|
||||
```typescript
|
||||
import { TimerServiceToken, createTimerService } from '@esengine/timer';
|
||||
|
||||
// 注册服务
|
||||
services.register(TimerServiceToken, createTimerService());
|
||||
|
||||
// 获取服务
|
||||
const timerService = services.get(TimerServiceToken);
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用有意义的 ID**:使用描述性的 ID 便于调试和管理
|
||||
```typescript
|
||||
// 好
|
||||
timerService.startCooldown('skill_fireball', 5000);
|
||||
|
||||
// 不好
|
||||
timerService.startCooldown('cd1', 5000);
|
||||
```
|
||||
|
||||
2. **避免重复 ID**:相同 ID 的定时器会覆盖之前的
|
||||
```typescript
|
||||
// 使用唯一 ID
|
||||
const uniqueId = `explosion_${entity.id}_${Date.now()}`;
|
||||
timerService.schedule(uniqueId, 1000, callback);
|
||||
```
|
||||
|
||||
3. **及时清理**:在适当时机清理不需要的定时器和冷却
|
||||
```typescript
|
||||
// 实体销毁时
|
||||
onDestroy() {
|
||||
this.timerService.cancelById(this.timerId);
|
||||
}
|
||||
```
|
||||
|
||||
4. **配置限制**:在生产环境考虑设置最大数量限制
|
||||
```typescript
|
||||
const timerService = createTimerService({
|
||||
maxTimers: 1000,
|
||||
maxCooldowns: 500
|
||||
});
|
||||
```
|
||||
38
package.json
38
package.json
@@ -5,7 +5,16 @@
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.22.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
"packages/framework/*",
|
||||
"packages/engine/*",
|
||||
"packages/rendering/*",
|
||||
"packages/physics/*",
|
||||
"packages/streaming/*",
|
||||
"packages/network-ext/*",
|
||||
"packages/editor/*",
|
||||
"packages/editor/plugins/*",
|
||||
"packages/rust/*",
|
||||
"packages/tools/*"
|
||||
],
|
||||
"keywords": [
|
||||
"ecs",
|
||||
@@ -17,6 +26,9 @@
|
||||
"egret"
|
||||
],
|
||||
"scripts": {
|
||||
"changeset": "changeset",
|
||||
"changeset:version": "changeset version",
|
||||
"changeset:publish": "changeset publish",
|
||||
"bootstrap": "lerna bootstrap",
|
||||
"clean": "turbo run clean",
|
||||
"build": "turbo run build",
|
||||
@@ -25,23 +37,24 @@
|
||||
"build:math": "turbo run build --filter=@esengine/ecs-framework-math",
|
||||
"build:editor": "turbo run build --filter=@esengine/editor-app...",
|
||||
"build:npm": "turbo run build:npm",
|
||||
"build:npm:core": "cd packages/core && npm run build:npm",
|
||||
"build:npm:math": "cd packages/math && npm run build:npm",
|
||||
"build:npm:core": "cd packages/framework/core && npm run build:npm",
|
||||
"build:npm:math": "cd packages/framework/math && npm run build:npm",
|
||||
"test": "turbo run test",
|
||||
"test:coverage": "turbo run test:coverage",
|
||||
"test:ci": "turbo run test:ci",
|
||||
"test:ci:framework": "turbo run test:ci --filter=@esengine/ecs-framework --filter=@esengine/ecs-framework-math --filter=@esengine/behavior-tree --filter=@esengine/blueprint --filter=@esengine/fsm --filter=@esengine/timer --filter=@esengine/spatial --filter=@esengine/procgen --filter=@esengine/pathfinding --filter=@esengine/network-protocols --filter=@esengine/network",
|
||||
"prepare:publish": "npm run build:npm && node scripts/pre-publish-check.cjs",
|
||||
"sync:versions": "node scripts/sync-versions.cjs",
|
||||
"publish:all": "npm run prepare:publish && npm run publish:all:dist",
|
||||
"publish:all:dist": "npm run publish:core && npm run publish:math",
|
||||
"publish:core": "cd packages/core && npm run publish:npm",
|
||||
"publish:core:patch": "cd packages/core && npm run publish:patch",
|
||||
"publish:math": "cd packages/math && npm run publish:npm",
|
||||
"publish:math:patch": "cd packages/math && npm run publish:patch",
|
||||
"publish:core": "cd packages/framework/core && npm run publish:npm",
|
||||
"publish:core:patch": "cd packages/framework/core && npm run publish:patch",
|
||||
"publish:math": "cd packages/framework/math && npm run publish:npm",
|
||||
"publish:math:patch": "cd packages/framework/math && npm run publish:patch",
|
||||
"publish": "lerna publish",
|
||||
"version": "lerna version",
|
||||
"release": "semantic-release",
|
||||
"release:core": "cd packages/core && semantic-release",
|
||||
"release:core": "cd packages/framework/core && semantic-release",
|
||||
"contributors:add": "all-contributors add",
|
||||
"contributors:generate": "all-contributors generate",
|
||||
"contributors:check": "all-contributors check",
|
||||
@@ -55,15 +68,19 @@
|
||||
"format": "prettier --write \"packages/**/src/**/*.{ts,tsx,js,jsx}\"",
|
||||
"format:check": "prettier --check \"packages/**/src/**/*.{ts,tsx,js,jsx}\"",
|
||||
"type-check": "turbo run type-check",
|
||||
"type-check:framework": "turbo run type-check --filter=@esengine/ecs-framework --filter=@esengine/ecs-framework-math --filter=@esengine/behavior-tree --filter=@esengine/blueprint --filter=@esengine/fsm --filter=@esengine/timer --filter=@esengine/spatial --filter=@esengine/procgen --filter=@esengine/pathfinding --filter=@esengine/network-protocols --filter=@esengine/network",
|
||||
"lint": "turbo run lint",
|
||||
"lint:framework": "turbo run lint --filter=@esengine/ecs-framework --filter=@esengine/ecs-framework-math --filter=@esengine/behavior-tree --filter=@esengine/blueprint --filter=@esengine/fsm --filter=@esengine/timer --filter=@esengine/spatial --filter=@esengine/procgen --filter=@esengine/pathfinding --filter=@esengine/network-protocols --filter=@esengine/network",
|
||||
"lint:fix": "turbo run lint:fix",
|
||||
"build:wasm": "cd packages/engine && wasm-pack build --dev --out-dir pkg",
|
||||
"build:wasm:release": "cd packages/engine && wasm-pack build --release --out-dir pkg",
|
||||
"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",
|
||||
"copy-modules": "node scripts/copy-engine-modules.mjs"
|
||||
},
|
||||
"author": "yhh",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@changesets/changelog-github": "^0.5.2",
|
||||
"@changesets/cli": "^2.29.8",
|
||||
"@commitlint/cli": "^18.6.0",
|
||||
"@commitlint/config-conventional": "^18.6.0",
|
||||
"@eslint/js": "^9.39.1",
|
||||
@@ -121,4 +138,3 @@
|
||||
"ws": "^8.18.2"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"name": "@esengine/audio",
|
||||
"version": "1.0.0",
|
||||
"description": "ECS-based audio system",
|
||||
"esengine": {
|
||||
"plugin": true,
|
||||
"pluginExport": "AudioPlugin",
|
||||
"category": "audio",
|
||||
"isEnginePlugin": true
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:watch": "tsup --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"rimraf": "^5.0.5",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"audio",
|
||||
"sound",
|
||||
"music"
|
||||
],
|
||||
"author": "yhh",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"references": [
|
||||
{ "path": "../core" }
|
||||
]
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"name": "@esengine/behavior-tree-editor",
|
||||
"version": "1.0.0",
|
||||
"description": "Editor support for @esengine/behavior-tree - visual editor, inspectors, and tools",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:watch": "tsup --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/behavior-tree": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
"@esengine/editor-runtime": "workspace:*",
|
||||
"@esengine/node-editor": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"lucide-react": "^0.545.0",
|
||||
"react": "^18.3.1",
|
||||
"zustand": "^5.0.8",
|
||||
"@types/react": "^18.3.12",
|
||||
"rimraf": "^5.0.5",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"behavior-tree",
|
||||
"editor"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../editor-core" },
|
||||
{ "path": "../behavior-tree" }
|
||||
]
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* @esengine/behavior-tree
|
||||
*
|
||||
* AI Behavior Tree System with runtime execution and visual editor support
|
||||
* AI 行为树系统,支持运行时执行和可视化编辑
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
// Constants
|
||||
export { BehaviorTreeAssetType } from './constants';
|
||||
|
||||
// Types
|
||||
export * from './Types/TaskStatus';
|
||||
|
||||
// Execution (runtime core)
|
||||
export * from './execution';
|
||||
|
||||
// Utilities
|
||||
export * from './BehaviorTreeStarter';
|
||||
export * from './BehaviorTreeBuilder';
|
||||
|
||||
// Serialization
|
||||
export * from './Serialization/NodeTemplates';
|
||||
export * from './Serialization/BehaviorTreeAsset';
|
||||
export * from './Serialization/EditorFormatConverter';
|
||||
export * from './Serialization/BehaviorTreeAssetSerializer';
|
||||
export * from './Serialization/EditorToBehaviorTreeDataConverter';
|
||||
|
||||
// Services
|
||||
export * from './Services/GlobalBlackboardService';
|
||||
|
||||
// Blackboard types (excluding BlackboardValueType which is already exported from TaskStatus)
|
||||
export type { BlackboardTypeDefinition } from './Blackboard/BlackboardTypes';
|
||||
export { BlackboardTypes } from './Blackboard/BlackboardTypes';
|
||||
|
||||
// Runtime module and plugin
|
||||
export { BehaviorTreeRuntimeModule, BehaviorTreePlugin } from './BehaviorTreeRuntimeModule';
|
||||
|
||||
// Service tokens | 服务令牌
|
||||
export { BehaviorTreeSystemToken } from './tokens';
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"name": "@esengine/blueprint-editor",
|
||||
"version": "1.0.0",
|
||||
"description": "Editor support for @esengine/blueprint - visual scripting editor",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:watch": "tsup --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/blueprint": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
"@esengine/node-editor": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"lucide-react": "^0.545.0",
|
||||
"react": "^18.3.1",
|
||||
"zustand": "^5.0.8",
|
||||
"@types/react": "^18.3.12",
|
||||
"rimraf": "^5.0.5",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"blueprint",
|
||||
"editor",
|
||||
"visual-scripting"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* @esengine/blueprint - Visual scripting system for ECS Framework
|
||||
* 蓝图可视化脚本系统
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Runtime
|
||||
export * from './runtime';
|
||||
|
||||
// Nodes (import to register)
|
||||
import './nodes';
|
||||
|
||||
// Re-export commonly used items
|
||||
export { NodeRegistry, RegisterNode } from './runtime/NodeRegistry';
|
||||
export { BlueprintVM } from './runtime/BlueprintVM';
|
||||
export {
|
||||
createBlueprintComponentData,
|
||||
initializeBlueprintVM,
|
||||
startBlueprint,
|
||||
stopBlueprint,
|
||||
tickBlueprint,
|
||||
cleanupBlueprint
|
||||
} from './runtime/BlueprintComponent';
|
||||
export {
|
||||
createBlueprintSystem,
|
||||
triggerBlueprintEvent,
|
||||
triggerCustomBlueprintEvent
|
||||
} from './runtime/BlueprintSystem';
|
||||
export { createEmptyBlueprint, validateBlueprintAsset } from './types/blueprint';
|
||||
|
||||
// Plugin
|
||||
export { BlueprintPlugin } from './BlueprintPlugin';
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* Event Nodes - Entry points for blueprint execution
|
||||
* 事件节点 - 蓝图执行的入口点
|
||||
*/
|
||||
|
||||
export * from './EventBeginPlay';
|
||||
export * from './EventTick';
|
||||
export * from './EventEndPlay';
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"name": "@esengine/build-config",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared build configuration for ES Engine packages",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./presets": "./src/presets/index.ts",
|
||||
"./presets/tsup": "./src/presets/plugin-tsup.ts",
|
||||
"./plugins": "./src/plugins/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"build",
|
||||
"tsup",
|
||||
"config"
|
||||
],
|
||||
"author": "yhh",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-dts": "^4.5.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tsup": "^8.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../camera" },
|
||||
{ "path": "../editor-core" }
|
||||
]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"references": [
|
||||
{ "path": "../core" }
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
import { runtimeOnlyPreset } from '../build-config/src/presets/plugin-tsup';
|
||||
|
||||
export default defineConfig({
|
||||
...runtimeOnlyPreset(),
|
||||
tsconfig: 'tsconfig.build.json'
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
import type { IComponent } from '../Types';
|
||||
import { Int32 } from './Core/SoAStorage';
|
||||
|
||||
/**
|
||||
* 游戏组件基类
|
||||
*
|
||||
* ECS架构中的组件(Component)应该是纯数据容器。
|
||||
* 所有游戏逻辑应该在 EntitySystem 中实现,而不是在组件内部。
|
||||
*
|
||||
* @example
|
||||
* 推荐做法:纯数据组件
|
||||
* ```typescript
|
||||
* class HealthComponent extends Component {
|
||||
* public health: number = 100;
|
||||
* public maxHealth: number = 100;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* 推荐做法:在 System 中处理逻辑
|
||||
* ```typescript
|
||||
* class HealthSystem extends EntitySystem {
|
||||
* process(entities: Entity[]): void {
|
||||
* for (const entity of entities) {
|
||||
* const health = entity.getComponent(HealthComponent);
|
||||
* if (health && health.health <= 0) {
|
||||
* entity.destroy();
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export abstract class Component implements IComponent {
|
||||
/**
|
||||
* 组件ID生成器
|
||||
*
|
||||
* 用于为每个组件分配唯一的ID。
|
||||
*
|
||||
* Component ID generator.
|
||||
* Used to assign unique IDs to each component.
|
||||
*/
|
||||
private static idGenerator: number = 0;
|
||||
|
||||
/**
|
||||
* 组件唯一标识符
|
||||
*
|
||||
* 在整个游戏生命周期中唯一的数字ID。
|
||||
*/
|
||||
public readonly id: number;
|
||||
|
||||
/**
|
||||
* 所属实体ID
|
||||
*
|
||||
* 存储实体ID而非引用,避免循环引用,符合ECS数据导向设计。
|
||||
*/
|
||||
@Int32
|
||||
public entityId: number | null = null;
|
||||
|
||||
/**
|
||||
* 最后写入的 epoch
|
||||
*
|
||||
* 用于帧级变更检测,记录组件最后一次被修改时的 epoch。
|
||||
* 0 表示从未被标记为已修改。
|
||||
*
|
||||
* Last write epoch.
|
||||
* Used for frame-level change detection, records the epoch when component was last modified.
|
||||
* 0 means never marked as modified.
|
||||
*/
|
||||
private _lastWriteEpoch: number = 0;
|
||||
|
||||
/**
|
||||
* 获取最后写入的 epoch
|
||||
*
|
||||
* Get last write epoch.
|
||||
*/
|
||||
public get lastWriteEpoch(): number {
|
||||
return this._lastWriteEpoch;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建组件实例
|
||||
*
|
||||
* 自动分配唯一ID给组件。
|
||||
*/
|
||||
constructor() {
|
||||
this.id = Component.idGenerator++;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记组件为已修改
|
||||
*
|
||||
* 调用此方法会更新组件的 lastWriteEpoch 为当前帧的 epoch。
|
||||
* 系统可以通过比较 lastWriteEpoch 和上次检查的 epoch 来判断组件是否发生变更。
|
||||
*
|
||||
* Mark component as modified.
|
||||
* Calling this method updates the component's lastWriteEpoch to the current frame's epoch.
|
||||
* Systems can compare lastWriteEpoch with their last checked epoch to detect changes.
|
||||
*
|
||||
* @param epoch 当前帧的 epoch | Current frame's epoch
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 在修改组件数据后调用
|
||||
* velocity.x = 10;
|
||||
* velocity.markDirty(scene.epochManager.current);
|
||||
* ```
|
||||
*/
|
||||
public markDirty(epoch: number): void {
|
||||
this._lastWriteEpoch = epoch;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件添加到实体时的回调
|
||||
*
|
||||
* 当组件被添加到实体时调用,可以在此方法中进行初始化操作。
|
||||
*
|
||||
* @remarks
|
||||
* 这是一个生命周期钩子,用于组件的初始化逻辑。
|
||||
* 虽然保留此方法,但建议将复杂的初始化逻辑放在 System 中处理。
|
||||
*/
|
||||
public onAddedToEntity(): void {}
|
||||
|
||||
/**
|
||||
* 组件从实体移除时的回调
|
||||
*
|
||||
* 当组件从实体中移除时调用,可以在此方法中进行清理操作。
|
||||
*
|
||||
* @remarks
|
||||
* 这是一个生命周期钩子,用于组件的清理逻辑。
|
||||
* 虽然保留此方法,但建议将复杂的清理逻辑放在 System 中处理。
|
||||
*/
|
||||
public onRemovedFromEntity(): void {}
|
||||
|
||||
/**
|
||||
* 组件反序列化后的回调
|
||||
*
|
||||
* 当组件从场景文件加载或快照恢复后调用,可以在此方法中恢复运行时数据。
|
||||
*
|
||||
* @remarks
|
||||
* 这是一个生命周期钩子,用于恢复无法序列化的运行时数据。
|
||||
* 例如:从图片路径重新加载图片尺寸信息,重建缓存等。
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class TilemapComponent extends Component {
|
||||
* public tilesetImage: string = '';
|
||||
* private _tilesetData: TilesetData | undefined;
|
||||
*
|
||||
* public async onDeserialized(): Promise<void> {
|
||||
* if (this.tilesetImage) {
|
||||
* // 重新加载 tileset 图片并恢复运行时数据
|
||||
* const img = await loadImage(this.tilesetImage);
|
||||
* this.setTilesetInfo(img.width, img.height, ...);
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
public onDeserialized(): void | Promise<void> {}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
// ECS系统导出
|
||||
export { EntitySystem } from './EntitySystem';
|
||||
export { ProcessingSystem } from './ProcessingSystem';
|
||||
export { PassiveSystem } from './PassiveSystem';
|
||||
export { IntervalSystem } from './IntervalSystem';
|
||||
export { WorkerEntitySystem } from './WorkerEntitySystem';
|
||||
export { HierarchySystem } from './HierarchySystem';
|
||||
|
||||
// Worker系统相关类型导出
|
||||
export type {
|
||||
WorkerProcessFunction,
|
||||
WorkerSystemConfig,
|
||||
SharedArrayBufferProcessFunction
|
||||
} from './WorkerEntitySystem';
|
||||
@@ -1,539 +0,0 @@
|
||||
import { IScene } from './IScene';
|
||||
import { Scene } from './Scene';
|
||||
import { createLogger } from '../Utils/Logger';
|
||||
import { PerformanceMonitor } from '../Utils/PerformanceMonitor';
|
||||
import { ServiceContainer } from '../Core/ServiceContainer';
|
||||
|
||||
const logger = createLogger('World');
|
||||
|
||||
/**
|
||||
* 全局系统接口
|
||||
* 全局系统是在World级别运行的系统,不依赖特定Scene
|
||||
*/
|
||||
export type IGlobalSystem = {
|
||||
/**
|
||||
* 系统名称
|
||||
*/
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* 初始化系统
|
||||
*/
|
||||
initialize?(): void;
|
||||
|
||||
/**
|
||||
* 更新系统
|
||||
*/
|
||||
update(deltaTime?: number): void;
|
||||
|
||||
/**
|
||||
* 重置系统
|
||||
*/
|
||||
reset?(): void;
|
||||
|
||||
/**
|
||||
* 销毁系统
|
||||
*/
|
||||
destroy?(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* World配置接口
|
||||
*/
|
||||
export type IWorldConfig = {
|
||||
/**
|
||||
* World名称
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* 是否启用调试模式
|
||||
*/
|
||||
debug?: boolean;
|
||||
|
||||
/**
|
||||
* 最大Scene数量限制
|
||||
*/
|
||||
maxScenes?: number;
|
||||
|
||||
/**
|
||||
* 是否自动清理空Scene
|
||||
*/
|
||||
autoCleanup?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* World类 - ECS世界管理器
|
||||
*
|
||||
* World是Scene的容器,每个World可以管理多个Scene。
|
||||
* World拥有独立的服务容器,用于管理World级别的全局服务。
|
||||
*
|
||||
* 服务容器层级:
|
||||
* - Core.services: 应用程序全局服务
|
||||
* - World.services: World级别服务(每个World独立)
|
||||
* - Scene.services: Scene级别服务(每个Scene独立)
|
||||
*
|
||||
* 这种设计允许创建独立的游戏世界,如:
|
||||
* - 游戏房间(每个房间一个World)
|
||||
* - 不同的游戏模式
|
||||
* - 独立的模拟环境
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 创建游戏房间的World
|
||||
* const roomWorld = new World({ name: 'Room_001' });
|
||||
*
|
||||
* // 注册World级别的服务
|
||||
* roomWorld.services.registerSingleton(RoomManager);
|
||||
*
|
||||
* // 在World中创建Scene
|
||||
* const gameScene = roomWorld.createScene('game', new Scene());
|
||||
* const uiScene = roomWorld.createScene('ui', new Scene());
|
||||
*
|
||||
* // 在Scene中使用World级别的服务
|
||||
* const roomManager = roomWorld.services.resolve(RoomManager);
|
||||
*
|
||||
* // 更新整个World
|
||||
* roomWorld.update(deltaTime);
|
||||
* ```
|
||||
*/
|
||||
export class World {
|
||||
public readonly name: string;
|
||||
private readonly _config: IWorldConfig;
|
||||
private readonly _scenes: Map<string, IScene> = new Map();
|
||||
private readonly _activeScenes: Set<string> = new Set();
|
||||
private readonly _globalSystems: IGlobalSystem[] = [];
|
||||
private readonly _services: ServiceContainer;
|
||||
private _isActive: boolean = false;
|
||||
private _createdAt: number;
|
||||
|
||||
constructor(config: IWorldConfig = {}) {
|
||||
this._config = {
|
||||
name: 'World',
|
||||
debug: false,
|
||||
maxScenes: 10,
|
||||
autoCleanup: true,
|
||||
...config
|
||||
};
|
||||
|
||||
this.name = this._config.name!;
|
||||
this._createdAt = Date.now();
|
||||
this._services = new ServiceContainer();
|
||||
}
|
||||
|
||||
/**
|
||||
* World级别的服务容器
|
||||
* 用于管理World范围内的全局服务
|
||||
*/
|
||||
public get services(): ServiceContainer {
|
||||
return this._services;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并添加Scene到World
|
||||
*/
|
||||
public createScene<T extends IScene>(sceneName: string, sceneInstance?: T): T {
|
||||
if (!sceneName || typeof sceneName !== 'string' || sceneName.trim() === '') {
|
||||
throw new Error('Scene name不能为空');
|
||||
}
|
||||
|
||||
if (this._scenes.has(sceneName)) {
|
||||
throw new Error(`Scene name '${sceneName}' 已存在于World '${this.name}' 中`);
|
||||
}
|
||||
|
||||
if (this._scenes.size >= this._config.maxScenes!) {
|
||||
throw new Error(`World '${this.name}' 已达到最大Scene数量限制: ${this._config.maxScenes}`);
|
||||
}
|
||||
|
||||
// 如果没有提供Scene实例,创建默认Scene
|
||||
const scene = sceneInstance || (new Scene() as unknown as T);
|
||||
|
||||
// 如果配置了 debug,为 Scene 注册并启用 PerformanceMonitor
|
||||
if (this._config.debug) {
|
||||
const performanceMonitor = new PerformanceMonitor();
|
||||
performanceMonitor.enable();
|
||||
scene.services.registerInstance(PerformanceMonitor, performanceMonitor);
|
||||
}
|
||||
|
||||
// 设置Scene的标识
|
||||
if ('id' in scene) {
|
||||
(scene as any).id = sceneName;
|
||||
}
|
||||
if ('name' in scene && !scene.name) {
|
||||
scene.name = sceneName;
|
||||
}
|
||||
|
||||
this._scenes.set(sceneName, scene);
|
||||
|
||||
// 初始化Scene
|
||||
scene.initialize();
|
||||
|
||||
return scene;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除Scene
|
||||
*/
|
||||
public removeScene(sceneName: string): boolean {
|
||||
const scene = this._scenes.get(sceneName);
|
||||
if (!scene) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果Scene正在运行,先停止它
|
||||
if (this._activeScenes.has(sceneName)) {
|
||||
this.setSceneActive(sceneName, false);
|
||||
}
|
||||
|
||||
// 清理Scene资源
|
||||
scene.end();
|
||||
this._scenes.delete(sceneName);
|
||||
|
||||
logger.info(`从World '${this.name}' 中移除Scene: ${sceneName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Scene
|
||||
*/
|
||||
public getScene<T extends IScene>(sceneName: string): T | null {
|
||||
return this._scenes.get(sceneName) as T || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有Scene ID
|
||||
*/
|
||||
public getSceneIds(): string[] {
|
||||
return Array.from(this._scenes.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有Scene
|
||||
*/
|
||||
public getAllScenes(): IScene[] {
|
||||
return Array.from(this._scenes.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除所有Scene
|
||||
*/
|
||||
public removeAllScenes(): void {
|
||||
const sceneNames = Array.from(this._scenes.keys());
|
||||
for (const sceneName of sceneNames) {
|
||||
this.removeScene(sceneName);
|
||||
}
|
||||
logger.info(`从World '${this.name}' 中移除所有Scene`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置Scene激活状态
|
||||
*/
|
||||
public setSceneActive(sceneName: string, active: boolean): void {
|
||||
const scene = this._scenes.get(sceneName);
|
||||
if (!scene) {
|
||||
logger.warn(`Scene '${sceneName}' 不存在于World '${this.name}' 中`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
this._activeScenes.add(sceneName);
|
||||
// 启动Scene
|
||||
if (scene.begin) {
|
||||
scene.begin();
|
||||
}
|
||||
logger.debug(`在World '${this.name}' 中激活Scene: ${sceneName}`);
|
||||
} else {
|
||||
this._activeScenes.delete(sceneName);
|
||||
// 可选择性地停止Scene,或者让它继续运行但不更新
|
||||
logger.debug(`在World '${this.name}' 中停用Scene: ${sceneName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查Scene是否激活
|
||||
*/
|
||||
public isSceneActive(sceneName: string): boolean {
|
||||
return this._activeScenes.has(sceneName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活跃Scene数量
|
||||
*/
|
||||
public getActiveSceneCount(): number {
|
||||
return this._activeScenes.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加全局System
|
||||
* 全局System会在所有激活Scene之前更新
|
||||
*/
|
||||
public addGlobalSystem<T extends IGlobalSystem>(system: T): T {
|
||||
if (this._globalSystems.includes(system)) {
|
||||
return system;
|
||||
}
|
||||
|
||||
this._globalSystems.push(system);
|
||||
if (system.initialize) {
|
||||
system.initialize();
|
||||
}
|
||||
|
||||
logger.debug(`在World '${this.name}' 中添加全局System: ${system.name}`);
|
||||
return system;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除全局System
|
||||
*/
|
||||
public removeGlobalSystem(system: IGlobalSystem): boolean {
|
||||
const index = this._globalSystems.indexOf(system);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._globalSystems.splice(index, 1);
|
||||
if (system.reset) {
|
||||
system.reset();
|
||||
}
|
||||
|
||||
logger.debug(`从World '${this.name}' 中移除全局System: ${system.name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全局System
|
||||
*/
|
||||
public getGlobalSystem<T extends IGlobalSystem>(type: new (...args: any[]) => T): T | null {
|
||||
for (const system of this._globalSystems) {
|
||||
if (system instanceof type) {
|
||||
return system as T;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动World
|
||||
*/
|
||||
public start(): void {
|
||||
if (this._isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._isActive = true;
|
||||
|
||||
// 启动所有全局System
|
||||
for (const system of this._globalSystems) {
|
||||
if (system.initialize) {
|
||||
system.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`启动World: ${this.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止World
|
||||
*/
|
||||
public stop(): void {
|
||||
if (!this._isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 停止所有Scene
|
||||
for (const sceneName of this._activeScenes) {
|
||||
this.setSceneActive(sceneName, false);
|
||||
}
|
||||
|
||||
// 重置所有全局System
|
||||
for (const system of this._globalSystems) {
|
||||
if (system.reset) {
|
||||
system.reset();
|
||||
}
|
||||
}
|
||||
|
||||
this._isActive = false;
|
||||
logger.info(`停止World: ${this.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新World中的全局System
|
||||
* 注意:此方法由Core.update()调用,不应直接调用
|
||||
*/
|
||||
public updateGlobalSystems(): void {
|
||||
if (!this._isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新全局System
|
||||
for (const system of this._globalSystems) {
|
||||
if (system.update) {
|
||||
system.update();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新World中的所有激活Scene
|
||||
* 注意:此方法由Core.update()调用,不应直接调用
|
||||
*/
|
||||
public updateScenes(): void {
|
||||
if (!this._isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新所有激活的Scene
|
||||
for (const sceneName of this._activeScenes) {
|
||||
const scene = this._scenes.get(sceneName);
|
||||
if (scene && scene.update) {
|
||||
scene.update();
|
||||
}
|
||||
}
|
||||
|
||||
// 自动清理(如果启用)
|
||||
if (this._config.autoCleanup && this.shouldAutoCleanup()) {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁World
|
||||
*/
|
||||
public destroy(): void {
|
||||
logger.info(`销毁World: ${this.name}`);
|
||||
|
||||
// 停止World
|
||||
this.stop();
|
||||
|
||||
// 销毁所有Scene
|
||||
const sceneNames = Array.from(this._scenes.keys());
|
||||
for (const sceneName of sceneNames) {
|
||||
this.removeScene(sceneName);
|
||||
}
|
||||
|
||||
// 清理全局System
|
||||
for (const system of this._globalSystems) {
|
||||
if (system.destroy) {
|
||||
system.destroy();
|
||||
} else if (system.reset) {
|
||||
system.reset();
|
||||
}
|
||||
}
|
||||
this._globalSystems.length = 0;
|
||||
|
||||
// 清空服务容器
|
||||
this._services.clear();
|
||||
|
||||
this._scenes.clear();
|
||||
this._activeScenes.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取World状态
|
||||
*/
|
||||
public getStatus() {
|
||||
return {
|
||||
name: this.name,
|
||||
isActive: this._isActive,
|
||||
sceneCount: this._scenes.size,
|
||||
activeSceneCount: this._activeScenes.size,
|
||||
globalSystemCount: this._globalSystems.length,
|
||||
createdAt: this._createdAt,
|
||||
config: { ...this._config },
|
||||
scenes: Array.from(this._scenes.keys()).map((sceneName) => ({
|
||||
id: sceneName,
|
||||
isActive: this._activeScenes.has(sceneName),
|
||||
name: this._scenes.get(sceneName)?.name || sceneName
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取World统计信息
|
||||
*/
|
||||
public getStats() {
|
||||
const stats = {
|
||||
totalEntities: 0,
|
||||
totalSystems: this._globalSystems.length,
|
||||
memoryUsage: 0,
|
||||
performance: {
|
||||
averageUpdateTime: 0,
|
||||
maxUpdateTime: 0
|
||||
}
|
||||
};
|
||||
|
||||
// 统计所有Scene的实体数量
|
||||
for (const scene of this._scenes.values()) {
|
||||
if (scene.entities) {
|
||||
stats.totalEntities += scene.entities.count;
|
||||
}
|
||||
if (scene.systems) {
|
||||
stats.totalSystems += scene.systems.length;
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否应该执行自动清理
|
||||
*/
|
||||
private shouldAutoCleanup(): boolean {
|
||||
// 简单的清理策略:如果有空Scene且超过5分钟没有实体
|
||||
const currentTime = Date.now();
|
||||
const cleanupThreshold = 5 * 60 * 1000; // 5分钟
|
||||
|
||||
for (const [sceneName, scene] of this._scenes) {
|
||||
if (!this._activeScenes.has(sceneName) &&
|
||||
scene.entities &&
|
||||
scene.entities.count === 0 &&
|
||||
(currentTime - this._createdAt) > cleanupThreshold) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行清理操作
|
||||
*/
|
||||
private cleanup(): void {
|
||||
const sceneNames = Array.from(this._scenes.keys());
|
||||
const currentTime = Date.now();
|
||||
const cleanupThreshold = 5 * 60 * 1000; // 5分钟
|
||||
|
||||
for (const sceneName of sceneNames) {
|
||||
const scene = this._scenes.get(sceneName);
|
||||
if (scene &&
|
||||
!this._activeScenes.has(sceneName) &&
|
||||
scene.entities &&
|
||||
scene.entities.count === 0 &&
|
||||
(currentTime - this._createdAt) > cleanupThreshold) {
|
||||
|
||||
this.removeScene(sceneName);
|
||||
logger.debug(`自动清理空Scene: ${sceneName} from World ${this.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查World是否激活
|
||||
*/
|
||||
public get isActive(): boolean {
|
||||
return this._isActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Scene数量
|
||||
*/
|
||||
public get sceneCount(): number {
|
||||
return this._scenes.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取创建时间
|
||||
*/
|
||||
public get createdAt(): number {
|
||||
return this._createdAt;
|
||||
}
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
import { World, IWorldConfig } from './World';
|
||||
import { createLogger } from '../Utils/Logger';
|
||||
import type { IService } from '../Core/ServiceContainer';
|
||||
|
||||
const logger = createLogger('WorldManager');
|
||||
|
||||
/**
|
||||
* WorldManager配置接口
|
||||
*/
|
||||
export type IWorldManagerConfig = {
|
||||
/**
|
||||
* 最大World数量
|
||||
*/
|
||||
maxWorlds?: number;
|
||||
|
||||
/**
|
||||
* 是否自动清理空World
|
||||
*/
|
||||
autoCleanup?: boolean;
|
||||
|
||||
/**
|
||||
* 清理间隔(帧数)
|
||||
*/
|
||||
cleanupFrameInterval?: number;
|
||||
|
||||
/**
|
||||
* 是否启用调试模式
|
||||
*/
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* World管理器 - 管理所有World实例
|
||||
*
|
||||
* WorldManager负责管理多个独立的World实例。
|
||||
* 每个World都是独立的ECS环境,可以包含多个Scene。
|
||||
*
|
||||
* 适用场景:
|
||||
* - MMO游戏的多房间管理
|
||||
* - 服务器端的多游戏实例
|
||||
* - 需要完全隔离的多个游戏环境
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 创建WorldManager实例
|
||||
* const worldManager = new WorldManager({
|
||||
* maxWorlds: 100,
|
||||
* autoCleanup: true
|
||||
* });
|
||||
*
|
||||
* // 创建游戏房间World
|
||||
* const room1 = worldManager.createWorld('room_001', {
|
||||
* name: 'GameRoom_001',
|
||||
* maxScenes: 5
|
||||
* });
|
||||
* room1.setActive(true);
|
||||
*
|
||||
* // 游戏循环
|
||||
* function gameLoop(deltaTime: number) {
|
||||
* Core.update(deltaTime);
|
||||
* worldManager.updateAll(); // 更新所有活跃World
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class WorldManager implements IService {
|
||||
private readonly _config: Required<IWorldManagerConfig>;
|
||||
private readonly _worlds: Map<string, World> = new Map();
|
||||
private _isRunning: boolean = false;
|
||||
private _framesSinceCleanup: number = 0;
|
||||
|
||||
public constructor(config: IWorldManagerConfig = {}) {
|
||||
this._config = {
|
||||
maxWorlds: 50,
|
||||
autoCleanup: true,
|
||||
cleanupFrameInterval: 1800, // 1800帧
|
||||
debug: false,
|
||||
...config
|
||||
};
|
||||
|
||||
// 默认启动运行状态
|
||||
this._isRunning = true;
|
||||
|
||||
logger.info('WorldManager已初始化', {
|
||||
maxWorlds: this._config.maxWorlds,
|
||||
autoCleanup: this._config.autoCleanup,
|
||||
cleanupFrameInterval: this._config.cleanupFrameInterval
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新World
|
||||
*/
|
||||
public createWorld(worldName: string, config?: IWorldConfig): World {
|
||||
if (!worldName || typeof worldName !== 'string' || worldName.trim() === '') {
|
||||
throw new Error('World name不能为空');
|
||||
}
|
||||
|
||||
if (this._worlds.has(worldName)) {
|
||||
throw new Error(`World name '${worldName}' 已存在`);
|
||||
}
|
||||
|
||||
if (this._worlds.size >= this._config.maxWorlds!) {
|
||||
throw new Error(`已达到最大World数量限制: ${this._config.maxWorlds}`);
|
||||
}
|
||||
|
||||
// 优先级:config.debug > WorldManager.debug > 默认
|
||||
const worldConfig: IWorldConfig = {
|
||||
name: worldName,
|
||||
debug: config?.debug ?? this._config.debug ?? false,
|
||||
...(config?.maxScenes !== undefined && { maxScenes: config.maxScenes }),
|
||||
...(config?.autoCleanup !== undefined && { autoCleanup: config.autoCleanup })
|
||||
};
|
||||
|
||||
const world = new World(worldConfig);
|
||||
this._worlds.set(worldName, world);
|
||||
|
||||
return world;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除World
|
||||
*/
|
||||
public removeWorld(worldName: string): boolean {
|
||||
const world = this._worlds.get(worldName);
|
||||
if (!world) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 销毁World
|
||||
world.destroy();
|
||||
this._worlds.delete(worldName);
|
||||
|
||||
logger.info(`移除World: ${worldName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取World
|
||||
*/
|
||||
public getWorld(worldName: string): World | null {
|
||||
return this._worlds.get(worldName) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有World ID
|
||||
*/
|
||||
public getWorldIds(): string[] {
|
||||
return Array.from(this._worlds.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有World
|
||||
*/
|
||||
public getAllWorlds(): World[] {
|
||||
return Array.from(this._worlds.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置World激活状态
|
||||
*/
|
||||
public setWorldActive(worldName: string, active: boolean): void {
|
||||
const world = this._worlds.get(worldName);
|
||||
if (!world) {
|
||||
logger.warn(`World '${worldName}' 不存在`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
world.start();
|
||||
logger.debug(`激活World: ${worldName}`);
|
||||
} else {
|
||||
world.stop();
|
||||
logger.debug(`停用World: ${worldName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查World是否激活
|
||||
*/
|
||||
public isWorldActive(worldName: string): boolean {
|
||||
const world = this._worlds.get(worldName);
|
||||
return world?.isActive ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新所有活跃的World
|
||||
*
|
||||
* 应该在每帧的游戏循环中调用。
|
||||
* 会自动更新所有活跃World的全局系统和场景。
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* function gameLoop(deltaTime: number) {
|
||||
* Core.update(deltaTime); // 更新全局服务
|
||||
* worldManager.updateAll(); // 更新所有World
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
public updateAll(): void {
|
||||
if (!this._isRunning) return;
|
||||
|
||||
for (const world of this._worlds.values()) {
|
||||
if (world.isActive) {
|
||||
// 更新World的全局System
|
||||
world.updateGlobalSystems();
|
||||
|
||||
// 更新World中的所有Scene
|
||||
world.updateScenes();
|
||||
}
|
||||
}
|
||||
|
||||
// 基于帧的自动清理
|
||||
if (this._config.autoCleanup) {
|
||||
this._framesSinceCleanup++;
|
||||
|
||||
if (this._framesSinceCleanup >= this._config.cleanupFrameInterval) {
|
||||
this.cleanup();
|
||||
this._framesSinceCleanup = 0; // 重置计数器
|
||||
|
||||
if (this._config.debug) {
|
||||
logger.debug(`执行定期清理World (间隔: ${this._config.cleanupFrameInterval} 帧)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有激活的World
|
||||
*/
|
||||
public getActiveWorlds(): World[] {
|
||||
const activeWorlds: World[] = [];
|
||||
for (const world of this._worlds.values()) {
|
||||
if (world.isActive) {
|
||||
activeWorlds.push(world);
|
||||
}
|
||||
}
|
||||
return activeWorlds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动所有World
|
||||
*/
|
||||
public startAll(): void {
|
||||
this._isRunning = true;
|
||||
|
||||
for (const world of this._worlds.values()) {
|
||||
world.start();
|
||||
}
|
||||
|
||||
logger.info('启动所有World');
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止所有World
|
||||
*/
|
||||
public stopAll(): void {
|
||||
this._isRunning = false;
|
||||
|
||||
for (const world of this._worlds.values()) {
|
||||
world.stop();
|
||||
}
|
||||
|
||||
logger.info('停止所有World');
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找满足条件的World
|
||||
*/
|
||||
public findWorlds(predicate: (world: World) => boolean): World[] {
|
||||
const results: World[] = [];
|
||||
for (const world of this._worlds.values()) {
|
||||
if (predicate(world)) {
|
||||
results.push(world);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据名称查找World
|
||||
*/
|
||||
public findWorldByName(name: string): World | null {
|
||||
for (const world of this._worlds.values()) {
|
||||
if (world.name === name) {
|
||||
return world;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取WorldManager统计信息
|
||||
*/
|
||||
public getStats() {
|
||||
const stats = {
|
||||
totalWorlds: this._worlds.size,
|
||||
activeWorlds: this.activeWorldCount,
|
||||
totalScenes: 0,
|
||||
totalEntities: 0,
|
||||
totalSystems: 0,
|
||||
memoryUsage: 0,
|
||||
isRunning: this._isRunning,
|
||||
config: { ...this._config },
|
||||
worlds: [] as any[]
|
||||
};
|
||||
|
||||
for (const [worldName, world] of this._worlds) {
|
||||
const worldStats = world.getStats();
|
||||
stats.totalScenes += worldStats.totalSystems; // World的getStats可能需要调整
|
||||
stats.totalEntities += worldStats.totalEntities;
|
||||
stats.totalSystems += worldStats.totalSystems;
|
||||
|
||||
stats.worlds.push({
|
||||
id: worldName,
|
||||
name: world.name,
|
||||
isActive: world.isActive,
|
||||
sceneCount: world.sceneCount,
|
||||
...worldStats
|
||||
});
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取详细状态信息
|
||||
*/
|
||||
public getDetailedStatus() {
|
||||
return {
|
||||
...this.getStats(),
|
||||
worlds: Array.from(this._worlds.entries()).map(([worldName, world]) => ({
|
||||
id: worldName,
|
||||
isActive: world.isActive,
|
||||
status: world.getStatus()
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理空World
|
||||
*/
|
||||
public cleanup(): number {
|
||||
const worldsToRemove: string[] = [];
|
||||
|
||||
for (const [worldName, world] of this._worlds) {
|
||||
if (this.shouldCleanupWorld(world)) {
|
||||
worldsToRemove.push(worldName);
|
||||
}
|
||||
}
|
||||
|
||||
for (const worldName of worldsToRemove) {
|
||||
this.removeWorld(worldName);
|
||||
}
|
||||
|
||||
if (worldsToRemove.length > 0) {
|
||||
logger.debug(`清理了 ${worldsToRemove.length} 个World`);
|
||||
}
|
||||
|
||||
return worldsToRemove.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁WorldManager
|
||||
*/
|
||||
public destroy(): void {
|
||||
logger.info('正在销毁WorldManager...');
|
||||
|
||||
// 停止所有World
|
||||
this.stopAll();
|
||||
|
||||
// 销毁所有World
|
||||
const worldNames = Array.from(this._worlds.keys());
|
||||
for (const worldName of worldNames) {
|
||||
this.removeWorld(worldName);
|
||||
}
|
||||
|
||||
this._worlds.clear();
|
||||
this._isRunning = false;
|
||||
|
||||
logger.info('WorldManager已销毁');
|
||||
}
|
||||
|
||||
/**
|
||||
* 实现 IService 接口的 dispose 方法
|
||||
* 调用 destroy 方法进行清理
|
||||
*/
|
||||
public dispose(): void {
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断World是否应该被清理
|
||||
* 清理策略:
|
||||
* 1. World未激活
|
||||
* 2. 没有Scene或所有Scene都是空的
|
||||
* 3. 创建时间超过10分钟
|
||||
*/
|
||||
private shouldCleanupWorld(world: World): boolean {
|
||||
if (world.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const age = Date.now() - world.createdAt;
|
||||
const isOldEnough = age > 10 * 60 * 1000; // 10分钟
|
||||
|
||||
if (world.sceneCount === 0) {
|
||||
return isOldEnough;
|
||||
}
|
||||
|
||||
// 检查是否所有Scene都是空的
|
||||
const allScenes = world.getAllScenes();
|
||||
const hasEntities = allScenes.some((scene) => scene.entities && scene.entities.count > 0);
|
||||
return !hasEntities && isOldEnough;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取World总数
|
||||
*/
|
||||
public get worldCount(): number {
|
||||
return this._worlds.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取激活World数量
|
||||
*/
|
||||
public get activeWorldCount(): number {
|
||||
let count = 0;
|
||||
for (const world of this._worlds.values()) {
|
||||
if (world.isActive) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否正在运行
|
||||
*/
|
||||
public get isRunning(): boolean {
|
||||
return this._isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置
|
||||
*/
|
||||
public get config(): IWorldManagerConfig {
|
||||
return { ...this._config };
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import type { IPlatformAdapter } from './IPlatformAdapter';
|
||||
import { createLogger, type ILogger } from '../Utils/Logger';
|
||||
|
||||
/**
|
||||
* 平台管理器
|
||||
* 用户需要手动注册平台适配器
|
||||
*/
|
||||
export class PlatformManager {
|
||||
private static instance: PlatformManager;
|
||||
private adapter: IPlatformAdapter | null = null;
|
||||
private readonly logger: ILogger;
|
||||
|
||||
private constructor() {
|
||||
this.logger = createLogger('PlatformManager');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
public static getInstance(): PlatformManager {
|
||||
if (!PlatformManager.instance) {
|
||||
PlatformManager.instance = new PlatformManager();
|
||||
}
|
||||
return PlatformManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前平台适配器
|
||||
*/
|
||||
public getAdapter(): IPlatformAdapter {
|
||||
if (!this.adapter) {
|
||||
throw new Error('平台适配器未注册,请调用 registerAdapter() 注册适配器');
|
||||
}
|
||||
return this.adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册平台适配器
|
||||
*/
|
||||
public registerAdapter(adapter: IPlatformAdapter): void {
|
||||
this.adapter = adapter;
|
||||
this.logger.info(`平台适配器已注册: ${adapter.name}`, {
|
||||
name: adapter.name,
|
||||
version: adapter.version,
|
||||
supportsWorker: adapter.isWorkerSupported(),
|
||||
supportsSharedArrayBuffer: adapter.isSharedArrayBufferSupported(),
|
||||
hardwareConcurrency: adapter.getHardwareConcurrency()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已注册适配器
|
||||
*/
|
||||
public hasAdapter(): boolean {
|
||||
return this.adapter !== null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取平台适配器信息(用于调试)
|
||||
*/
|
||||
public getAdapterInfo(): any {
|
||||
return this.adapter ? {
|
||||
name: this.adapter.name,
|
||||
version: this.adapter.version,
|
||||
config: this.adapter.getPlatformConfig()
|
||||
} : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查当前平台是否支持特定功能
|
||||
*/
|
||||
public supportsFeature(feature: 'worker' | 'shared-array-buffer' | 'transferable-objects' | 'module-worker'): boolean {
|
||||
if (!this.adapter) return false;
|
||||
|
||||
const config = this.adapter.getPlatformConfig();
|
||||
|
||||
switch (feature) {
|
||||
case 'worker':
|
||||
return this.adapter.isWorkerSupported();
|
||||
case 'shared-array-buffer':
|
||||
return this.adapter.isSharedArrayBufferSupported();
|
||||
case 'transferable-objects':
|
||||
return config.supportsTransferableObjects;
|
||||
case 'module-worker':
|
||||
return config.supportsModuleWorker;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取基础的Worker配置信息(不做自动决策)
|
||||
* 用户应该根据自己的业务需求来配置Worker参数
|
||||
*/
|
||||
public getBasicWorkerConfig(): {
|
||||
platformSupportsWorker: boolean;
|
||||
platformSupportsSharedArrayBuffer: boolean;
|
||||
platformMaxWorkerCount: number;
|
||||
platformLimitations: any;
|
||||
} {
|
||||
if (!this.adapter) {
|
||||
return {
|
||||
platformSupportsWorker: false,
|
||||
platformSupportsSharedArrayBuffer: false,
|
||||
platformMaxWorkerCount: 1,
|
||||
platformLimitations: {}
|
||||
};
|
||||
}
|
||||
|
||||
const config = this.adapter.getPlatformConfig();
|
||||
|
||||
return {
|
||||
platformSupportsWorker: this.adapter.isWorkerSupported(),
|
||||
platformSupportsSharedArrayBuffer: this.adapter.isSharedArrayBufferSupported(),
|
||||
platformMaxWorkerCount: config.maxWorkerCount,
|
||||
platformLimitations: config.limitations || {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步获取完整的平台配置信息(包含性能信息)
|
||||
*/
|
||||
public async getFullPlatformConfig(): Promise<any> {
|
||||
if (!this.adapter) {
|
||||
throw new Error('平台适配器未注册');
|
||||
}
|
||||
|
||||
// 如果适配器支持异步获取配置,使用异步方法
|
||||
if (typeof this.adapter.getPlatformConfigAsync === 'function') {
|
||||
return await this.adapter.getPlatformConfigAsync();
|
||||
}
|
||||
|
||||
// 否则返回同步配置
|
||||
return this.adapter.getPlatformConfig();
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"name": "@esengine/ecs-engine-bindgen",
|
||||
"version": "0.1.0",
|
||||
"description": "Bridge layer between ECS Framework and Rust Engine | ECS框架与Rust引擎之间的桥接层",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:watch": "tsup --watch",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/esengine/esengine.git",
|
||||
"directory": "packages/ecs-engine-bindgen"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"game-engine",
|
||||
"bridge",
|
||||
"wasm",
|
||||
"typescript"
|
||||
],
|
||||
"author": "ESEngine Team",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"es-engine": "file:../engine/pkg"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/ecs-framework-math": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/sprite": "workspace:*",
|
||||
"@esengine/camera": "workspace:*",
|
||||
"@esengine/asset-system": "workspace:*",
|
||||
"@esengine/material-system": "workspace:*",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.8.0",
|
||||
"rimraf": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* 编辑器交互状态
|
||||
* 管理编辑器的交互状态(连接、框选、菜单等)
|
||||
*/
|
||||
interface EditorState {
|
||||
/**
|
||||
* 正在连接的源节点ID
|
||||
*/
|
||||
connectingFrom: string | null;
|
||||
|
||||
/**
|
||||
* 正在连接的源属性
|
||||
*/
|
||||
connectingFromProperty: string | null;
|
||||
|
||||
/**
|
||||
* 连接目标位置(鼠标位置)
|
||||
*/
|
||||
connectingToPos: { x: number; y: number } | null;
|
||||
|
||||
/**
|
||||
* 是否正在框选
|
||||
*/
|
||||
isBoxSelecting: boolean;
|
||||
|
||||
/**
|
||||
* 框选起始位置
|
||||
*/
|
||||
boxSelectStart: { x: number; y: number } | null;
|
||||
|
||||
/**
|
||||
* 框选结束位置
|
||||
*/
|
||||
boxSelectEnd: { x: number; y: number } | null;
|
||||
|
||||
// Actions
|
||||
setConnectingFrom: (nodeId: string | null) => void;
|
||||
setConnectingFromProperty: (propertyName: string | null) => void;
|
||||
setConnectingToPos: (pos: { x: number; y: number } | null) => void;
|
||||
clearConnecting: () => void;
|
||||
|
||||
setIsBoxSelecting: (isSelecting: boolean) => void;
|
||||
setBoxSelectStart: (pos: { x: number; y: number } | null) => void;
|
||||
setBoxSelectEnd: (pos: { x: number; y: number } | null) => void;
|
||||
clearBoxSelect: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor Store
|
||||
*/
|
||||
export const useEditorStore = create<EditorState>((set) => ({
|
||||
connectingFrom: null,
|
||||
connectingFromProperty: null,
|
||||
connectingToPos: null,
|
||||
|
||||
isBoxSelecting: false,
|
||||
boxSelectStart: null,
|
||||
boxSelectEnd: null,
|
||||
|
||||
setConnectingFrom: (nodeId: string | null) => set({ connectingFrom: nodeId }),
|
||||
|
||||
setConnectingFromProperty: (propertyName: string | null) =>
|
||||
set({ connectingFromProperty: propertyName }),
|
||||
|
||||
setConnectingToPos: (pos: { x: number; y: number } | null) => set({ connectingToPos: pos }),
|
||||
|
||||
clearConnecting: () =>
|
||||
set({
|
||||
connectingFrom: null,
|
||||
connectingFromProperty: null,
|
||||
connectingToPos: null
|
||||
}),
|
||||
|
||||
setIsBoxSelecting: (isSelecting: boolean) => set({ isBoxSelecting: isSelecting }),
|
||||
|
||||
setBoxSelectStart: (pos: { x: number; y: number } | null) => set({ boxSelectStart: pos }),
|
||||
|
||||
setBoxSelectEnd: (pos: { x: number; y: number } | null) => set({ boxSelectEnd: pos }),
|
||||
|
||||
clearBoxSelect: () =>
|
||||
set({
|
||||
isBoxSelecting: false,
|
||||
boxSelectStart: null,
|
||||
boxSelectEnd: null
|
||||
})
|
||||
}));
|
||||
@@ -1,131 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* UI 状态
|
||||
* 管理UI相关的状态(选中、拖拽、画布)
|
||||
*/
|
||||
interface UIState {
|
||||
/**
|
||||
* 选中的节点ID列表
|
||||
*/
|
||||
selectedNodeIds: string[];
|
||||
|
||||
/**
|
||||
* 正在拖拽的节点ID
|
||||
*/
|
||||
draggingNodeId: string | null;
|
||||
|
||||
/**
|
||||
* 拖拽起始位置映射
|
||||
*/
|
||||
dragStartPositions: Map<string, { x: number; y: number }>;
|
||||
|
||||
/**
|
||||
* 是否正在拖拽节点
|
||||
*/
|
||||
isDraggingNode: boolean;
|
||||
|
||||
/**
|
||||
* 拖拽偏移量
|
||||
*/
|
||||
dragDelta: { dx: number; dy: number };
|
||||
|
||||
/**
|
||||
* 画布偏移
|
||||
*/
|
||||
canvasOffset: { x: number; y: number };
|
||||
|
||||
/**
|
||||
* 画布缩放
|
||||
*/
|
||||
canvasScale: number;
|
||||
|
||||
/**
|
||||
* 是否正在平移画布
|
||||
*/
|
||||
isPanning: boolean;
|
||||
|
||||
/**
|
||||
* 平移起始位置
|
||||
*/
|
||||
panStart: { x: number; y: number };
|
||||
|
||||
// Actions
|
||||
setSelectedNodeIds: (nodeIds: string[]) => void;
|
||||
toggleNodeSelection: (nodeId: string) => void;
|
||||
clearSelection: () => void;
|
||||
|
||||
startDragging: (nodeId: string, startPositions: Map<string, { x: number; y: number }>) => void;
|
||||
stopDragging: () => void;
|
||||
setIsDraggingNode: (isDragging: boolean) => void;
|
||||
setDragDelta: (delta: { dx: number; dy: number }) => void;
|
||||
|
||||
setCanvasOffset: (offset: { x: number; y: number }) => void;
|
||||
setCanvasScale: (scale: number) => void;
|
||||
setIsPanning: (isPanning: boolean) => void;
|
||||
setPanStart: (panStart: { x: number; y: number }) => void;
|
||||
resetView: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI Store
|
||||
*/
|
||||
export const useUIStore = create<UIState>((set, get) => ({
|
||||
selectedNodeIds: [],
|
||||
draggingNodeId: null,
|
||||
dragStartPositions: new Map(),
|
||||
isDraggingNode: false,
|
||||
dragDelta: { dx: 0, dy: 0 },
|
||||
|
||||
canvasOffset: { x: 0, y: 0 },
|
||||
canvasScale: 1,
|
||||
isPanning: false,
|
||||
panStart: { x: 0, y: 0 },
|
||||
|
||||
setSelectedNodeIds: (nodeIds: string[]) => set({ selectedNodeIds: nodeIds }),
|
||||
|
||||
toggleNodeSelection: (nodeId: string) => {
|
||||
const { selectedNodeIds } = get();
|
||||
if (selectedNodeIds.includes(nodeId)) {
|
||||
set({ selectedNodeIds: selectedNodeIds.filter((id) => id !== nodeId) });
|
||||
} else {
|
||||
set({ selectedNodeIds: [...selectedNodeIds, nodeId] });
|
||||
}
|
||||
},
|
||||
|
||||
clearSelection: () => set({ selectedNodeIds: [] }),
|
||||
|
||||
startDragging: (nodeId: string, startPositions: Map<string, { x: number; y: number }>) =>
|
||||
set({
|
||||
draggingNodeId: nodeId,
|
||||
dragStartPositions: startPositions,
|
||||
isDraggingNode: true
|
||||
}),
|
||||
|
||||
stopDragging: () =>
|
||||
set({
|
||||
draggingNodeId: null,
|
||||
dragStartPositions: new Map(),
|
||||
isDraggingNode: false,
|
||||
dragDelta: { dx: 0, dy: 0 }
|
||||
}),
|
||||
|
||||
setIsDraggingNode: (isDragging: boolean) => set({ isDraggingNode: isDragging }),
|
||||
|
||||
setDragDelta: (delta: { dx: number; dy: number }) => set({ dragDelta: delta }),
|
||||
|
||||
setCanvasOffset: (offset: { x: number; y: number }) => set({ canvasOffset: offset }),
|
||||
|
||||
setCanvasScale: (scale: number) => set({ canvasScale: scale }),
|
||||
|
||||
setIsPanning: (isPanning: boolean) => set({ isPanning }),
|
||||
|
||||
setPanStart: (panStart: { x: number; y: number }) => set({ panStart }),
|
||||
|
||||
resetView: () =>
|
||||
set({
|
||||
canvasOffset: { x: 0, y: 0 },
|
||||
canvasScale: 1,
|
||||
isPanning: false
|
||||
})
|
||||
}));
|
||||
@@ -1,2 +0,0 @@
|
||||
export { useUIStore } from './UIStore';
|
||||
export { useEditorStore } from './EditorStore';
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Asset Browser - 资产浏览器
|
||||
* 包装 ContentBrowser 组件,保持向后兼容
|
||||
*/
|
||||
|
||||
import { ContentBrowser } from './ContentBrowser';
|
||||
|
||||
interface AssetBrowserProps {
|
||||
projectPath: string | null;
|
||||
locale: string;
|
||||
onOpenScene?: (scenePath: string) => void;
|
||||
}
|
||||
|
||||
export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserProps) {
|
||||
return (
|
||||
<ContentBrowser
|
||||
projectPath={projectPath}
|
||||
locale={locale}
|
||||
onOpenScene={onOpenScene}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { RefreshCw, Folder } from 'lucide-react';
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
|
||||
interface AssetPickerProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
projectPath: string | null;
|
||||
filter?: 'btree' | 'ecs';
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资产选择器组件
|
||||
* 用于选择项目中的资产文件
|
||||
*/
|
||||
export function AssetPicker({ value, onChange, projectPath, filter = 'btree', label }: AssetPickerProps) {
|
||||
const [assets, setAssets] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectPath) {
|
||||
loadAssets();
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
const loadAssets = async () => {
|
||||
if (!projectPath) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (filter === 'btree') {
|
||||
const btrees = await TauriAPI.scanBehaviorTrees(projectPath);
|
||||
setAssets(btrees);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load assets:', error);
|
||||
setAssets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowse = async () => {
|
||||
try {
|
||||
if (filter === 'btree') {
|
||||
const path = await TauriAPI.openBehaviorTreeDialog();
|
||||
if (path && projectPath) {
|
||||
const behaviorsPath = `${projectPath}\\.ecs\\behaviors\\`.replace(/\\/g, '\\\\');
|
||||
const relativePath = path.replace(behaviorsPath, '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace('.btree', '');
|
||||
onChange(relativePath);
|
||||
await loadAssets();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to browse asset:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{label && (
|
||||
<label style={{ fontSize: '11px', color: '#aaa', fontWeight: '500' }}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={loading || !projectPath}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3e3e42',
|
||||
borderRadius: '3px',
|
||||
color: '#cccccc',
|
||||
fontSize: '12px',
|
||||
cursor: loading || !projectPath ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
<option value="">{loading ? '加载中...' : '选择资产...'}</option>
|
||||
{assets.map((asset) => (
|
||||
<option key={asset} value={asset}>
|
||||
{asset}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={loadAssets}
|
||||
disabled={loading || !projectPath}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#fff',
|
||||
cursor: loading || !projectPath ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: loading || !projectPath ? 0.5 : 1
|
||||
}}
|
||||
title="刷新资产列表"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
disabled={loading || !projectPath}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#fff',
|
||||
cursor: loading || !projectPath ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: loading || !projectPath ? 0.5 : 1
|
||||
}}
|
||||
title="浏览文件..."
|
||||
>
|
||||
<Folder size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{!projectPath && (
|
||||
<div style={{ fontSize: '10px', color: '#ff6b6b', marginTop: '2px' }}>
|
||||
未加载项目
|
||||
</div>
|
||||
)}
|
||||
{value && assets.length > 0 && !assets.includes(value) && (
|
||||
<div style={{ fontSize: '10px', color: '#ffa726', marginTop: '2px' }}>
|
||||
警告: 资产 "{value}" 不存在于项目中
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Folder, Search, ArrowLeft, Grid, List, FileCode } from 'lucide-react';
|
||||
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import '../styles/AssetPickerDialog.css';
|
||||
|
||||
interface AssetPickerDialogProps {
|
||||
projectPath: string;
|
||||
fileExtension: string;
|
||||
onSelect: (assetId: string) => void;
|
||||
onClose: () => void;
|
||||
locale: string;
|
||||
/** 资产基础路径(相对于项目根目录),用于计算 assetId */
|
||||
assetBasePath?: string;
|
||||
}
|
||||
|
||||
interface AssetItem {
|
||||
name: string;
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
extension?: string;
|
||||
size?: number;
|
||||
modified?: number;
|
||||
}
|
||||
|
||||
type ViewMode = 'list' | 'grid';
|
||||
|
||||
export function AssetPickerDialog({ projectPath, fileExtension, onSelect, onClose, locale, assetBasePath }: AssetPickerDialogProps) {
|
||||
const { t, locale: currentLocale } = useLocale();
|
||||
|
||||
// 计算实际的资产目录路径
|
||||
const actualAssetPath = assetBasePath
|
||||
? `${projectPath}/${assetBasePath}`.replace(/\\/g, '/').replace(/\/+/g, '/')
|
||||
: projectPath;
|
||||
|
||||
const [currentPath, setCurrentPath] = useState(actualAssetPath);
|
||||
const [assets, setAssets] = useState<AssetItem[]>([]);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
|
||||
useEffect(() => {
|
||||
loadAssets(currentPath);
|
||||
}, [currentPath]);
|
||||
|
||||
const loadAssets = async (path: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const entries = await TauriAPI.listDirectory(path);
|
||||
const assetItems: AssetItem[] = entries
|
||||
.map((entry: DirectoryEntry) => {
|
||||
const extension = entry.is_dir ? undefined :
|
||||
(entry.name.includes('.') ? entry.name.split('.').pop() : undefined);
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
isDir: entry.is_dir,
|
||||
extension,
|
||||
size: entry.size,
|
||||
modified: entry.modified
|
||||
};
|
||||
})
|
||||
.filter((item) => item.isDir || item.extension === fileExtension)
|
||||
.sort((a, b) => {
|
||||
if (a.isDir === b.isDir) return a.name.localeCompare(b.name);
|
||||
return a.isDir ? -1 : 1;
|
||||
});
|
||||
|
||||
setAssets(assetItems);
|
||||
} catch (error) {
|
||||
console.error('Failed to load assets:', error);
|
||||
setAssets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 过滤搜索结果
|
||||
const filteredAssets = assets.filter((item) =>
|
||||
item.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes?: number): string => {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
// 格式化修改时间
|
||||
const formatDate = (timestamp?: number): string => {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp * 1000);
|
||||
const localeMap: Record<string, string> = { zh: 'zh-CN', en: 'en-US', es: 'es-ES' };
|
||||
return date.toLocaleDateString(localeMap[currentLocale] || 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// 返回上级目录
|
||||
const handleGoBack = () => {
|
||||
const parentPath = currentPath.split(/[/\\]/).slice(0, -1).join('/');
|
||||
const minPath = actualAssetPath.replace(/[/\\]$/, '');
|
||||
if (parentPath && parentPath !== minPath) {
|
||||
setCurrentPath(parentPath);
|
||||
} else if (currentPath !== actualAssetPath) {
|
||||
setCurrentPath(actualAssetPath);
|
||||
}
|
||||
};
|
||||
|
||||
// 只能返回到资产基础目录,不能再往上
|
||||
const canGoBack = currentPath !== actualAssetPath;
|
||||
|
||||
const handleItemClick = (item: AssetItem) => {
|
||||
if (item.isDir) {
|
||||
setCurrentPath(item.path);
|
||||
} else {
|
||||
setSelectedPath(item.path);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemDoubleClick = (item: AssetItem) => {
|
||||
if (!item.isDir) {
|
||||
const assetId = calculateAssetId(item.path);
|
||||
onSelect(assetId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = () => {
|
||||
if (selectedPath) {
|
||||
const assetId = calculateAssetId(selectedPath);
|
||||
onSelect(assetId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算资产ID
|
||||
* 将绝对路径转换为相对于资产基础目录的assetId(不含扩展名)
|
||||
*/
|
||||
const calculateAssetId = (absolutePath: string): string => {
|
||||
const normalized = absolutePath.replace(/\\/g, '/');
|
||||
const baseNormalized = actualAssetPath.replace(/\\/g, '/');
|
||||
|
||||
// 获取相对于资产基础目录的路径
|
||||
let relativePath = normalized;
|
||||
if (normalized.startsWith(baseNormalized)) {
|
||||
relativePath = normalized.substring(baseNormalized.length);
|
||||
}
|
||||
|
||||
// 移除开头的斜杠
|
||||
relativePath = relativePath.replace(/^\/+/, '');
|
||||
|
||||
// 移除文件扩展名
|
||||
const assetId = relativePath.replace(new RegExp(`\\.${fileExtension}$`), '');
|
||||
|
||||
return assetId;
|
||||
};
|
||||
|
||||
const getBreadcrumbs = () => {
|
||||
const basePathNormalized = actualAssetPath.replace(/\\/g, '/');
|
||||
const currentPathNormalized = currentPath.replace(/\\/g, '/');
|
||||
|
||||
const relative = currentPathNormalized.replace(basePathNormalized, '');
|
||||
const parts = relative.split('/').filter((p) => p);
|
||||
|
||||
// 根路径名称(显示"行为树"或"Assets")
|
||||
const rootName = assetBasePath
|
||||
? assetBasePath.split('/').pop() || 'Assets'
|
||||
: 'Content';
|
||||
|
||||
const crumbs = [{ name: rootName, path: actualAssetPath }];
|
||||
let accPath = actualAssetPath;
|
||||
|
||||
for (const part of parts) {
|
||||
accPath = `${accPath}/${part}`;
|
||||
crumbs.push({ name: part, path: accPath });
|
||||
}
|
||||
|
||||
return crumbs;
|
||||
};
|
||||
|
||||
const breadcrumbs = getBreadcrumbs();
|
||||
|
||||
return (
|
||||
<div className="asset-picker-overlay" onClick={onClose}>
|
||||
<div className="asset-picker-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="asset-picker-header">
|
||||
<h3>{t('assetPicker.title')}</h3>
|
||||
<button className="asset-picker-close" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="asset-picker-toolbar">
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={handleGoBack}
|
||||
disabled={!canGoBack}
|
||||
title={t('assetPicker.back')}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
|
||||
<div className="asset-picker-breadcrumb">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<span key={crumb.path}>
|
||||
<span
|
||||
className="breadcrumb-item"
|
||||
onClick={() => setCurrentPath(crumb.path)}
|
||||
>
|
||||
{crumb.name}
|
||||
</span>
|
||||
{index < breadcrumbs.length - 1 && <span className="breadcrumb-separator"> / </span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="view-mode-buttons">
|
||||
<button
|
||||
className={`toolbar-button ${viewMode === 'list' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
title={t('assetPicker.listView')}
|
||||
>
|
||||
<List size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`toolbar-button ${viewMode === 'grid' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
title={t('assetPicker.gridView')}
|
||||
>
|
||||
<Grid size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="asset-picker-search">
|
||||
<Search size={16} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('assetPicker.search')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="search-clear"
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="asset-picker-content">
|
||||
{loading ? (
|
||||
<div className="asset-picker-loading">{t('assetPicker.loading')}</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="asset-picker-empty">{t('assetPicker.empty')}</div>
|
||||
) : (
|
||||
<div className={`asset-picker-list ${viewMode}`}>
|
||||
{filteredAssets.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`asset-picker-item ${selectedPath === item.path ? 'selected' : ''}`}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onDoubleClick={() => handleItemDoubleClick(item)}
|
||||
>
|
||||
<div className="asset-icon">
|
||||
{item.isDir ? (
|
||||
<Folder size={viewMode === 'grid' ? 32 : 18} style={{ color: '#ffa726' }} />
|
||||
) : (
|
||||
<FileCode size={viewMode === 'grid' ? 32 : 18} style={{ color: '#66bb6a' }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="asset-info">
|
||||
<span className="asset-name">{item.name}</span>
|
||||
{viewMode === 'list' && !item.isDir && (
|
||||
<div className="asset-meta">
|
||||
{item.size && <span className="asset-size">{formatFileSize(item.size)}</span>}
|
||||
{item.modified && <span className="asset-date">{formatDate(item.modified)}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="asset-picker-footer">
|
||||
<div className="footer-info">
|
||||
{t('assetPicker.itemCount', { count: filteredAssets.length })}
|
||||
</div>
|
||||
<div className="footer-buttons">
|
||||
<button className="asset-picker-cancel" onClick={onClose}>
|
||||
{t('assetPicker.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="asset-picker-select"
|
||||
onClick={handleSelect}
|
||||
disabled={!selectedPath}
|
||||
>
|
||||
{t('assetPicker.select')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,541 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Entity } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { PropertyInspector } from './PropertyInspector';
|
||||
import { FileSearch, ChevronDown, ChevronRight, X, Settings } from 'lucide-react';
|
||||
import '../styles/EntityInspector.css';
|
||||
|
||||
interface EntityInspectorProps {
|
||||
entityStore: EntityStoreService;
|
||||
messageHub: MessageHub;
|
||||
}
|
||||
|
||||
export function EntityInspector({ entityStore: _entityStore, messageHub }: EntityInspectorProps) {
|
||||
const [selectedEntity, setSelectedEntity] = useState<Entity | null>(null);
|
||||
const [remoteEntity, setRemoteEntity] = useState<any | null>(null);
|
||||
const [remoteEntityDetails, setRemoteEntityDetails] = useState<any | null>(null);
|
||||
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(new Set());
|
||||
const [componentVersion, setComponentVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSelection = (data: { entity: Entity | null }) => {
|
||||
setSelectedEntity((prev) => {
|
||||
// Only reset version when selecting a different entity
|
||||
// 只在选择不同实体时重置版本
|
||||
if (prev?.id !== data.entity?.id) {
|
||||
setComponentVersion(0);
|
||||
} else {
|
||||
// Same entity re-selected, trigger refresh
|
||||
// 同一实体重新选择,触发刷新
|
||||
setComponentVersion((v) => v + 1);
|
||||
}
|
||||
return data.entity;
|
||||
});
|
||||
setRemoteEntity(null);
|
||||
setRemoteEntityDetails(null);
|
||||
};
|
||||
|
||||
const handleRemoteSelection = (data: { entity: any }) => {
|
||||
setRemoteEntity(data.entity);
|
||||
setRemoteEntityDetails(null);
|
||||
setSelectedEntity(null);
|
||||
};
|
||||
|
||||
const handleEntityDetails = (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const details = customEvent.detail;
|
||||
setRemoteEntityDetails(details);
|
||||
};
|
||||
|
||||
const handleComponentChange = () => {
|
||||
setComponentVersion((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const unsubSelect = messageHub.subscribe('entity:selected', handleSelection);
|
||||
const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteSelection);
|
||||
const unsubComponentAdded = messageHub.subscribe('component:added', handleComponentChange);
|
||||
const unsubComponentRemoved = messageHub.subscribe('component:removed', handleComponentChange);
|
||||
const unsubPropertyChanged = messageHub.subscribe('component:property:changed', handleComponentChange);
|
||||
|
||||
window.addEventListener('profiler:entity-details', handleEntityDetails);
|
||||
|
||||
return () => {
|
||||
unsubSelect();
|
||||
unsubRemoteSelect();
|
||||
unsubComponentAdded();
|
||||
unsubComponentRemoved();
|
||||
unsubPropertyChanged();
|
||||
window.removeEventListener('profiler:entity-details', handleEntityDetails);
|
||||
};
|
||||
}, [messageHub]);
|
||||
|
||||
const handleRemoveComponent = (index: number) => {
|
||||
if (!selectedEntity) return;
|
||||
const component = selectedEntity.components[index];
|
||||
if (component) {
|
||||
selectedEntity.removeComponent(component);
|
||||
messageHub.publish('component:removed', { entity: selectedEntity, component });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleComponentExpanded = (index: number) => {
|
||||
setExpandedComponents((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handlePropertyChange = (component: any, propertyName: string, value: any) => {
|
||||
if (!selectedEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Actually update the component property
|
||||
// 实际更新组件属性
|
||||
component[propertyName] = value;
|
||||
|
||||
messageHub.publish('component:property:changed', {
|
||||
entity: selectedEntity,
|
||||
component,
|
||||
propertyName,
|
||||
value
|
||||
});
|
||||
|
||||
// Also publish scene:modified so other panels can react
|
||||
messageHub.publish('scene:modified', {});
|
||||
};
|
||||
|
||||
const renderRemoteProperty = (key: string, value: any) => {
|
||||
if (value === null || value === undefined) {
|
||||
return (
|
||||
<div key={key} className="property-field">
|
||||
<label className="property-label">{key}</label>
|
||||
<span className="property-value-text">null</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<div key={key} className="property-field">
|
||||
<label className="property-label">{key}</label>
|
||||
<div style={{ flex: 1, display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{value.length === 0 ? (
|
||||
<span className="property-value-text" style={{ opacity: 0.5 }}>Empty Array</span>
|
||||
) : (
|
||||
value.map((item, index) => (
|
||||
<span
|
||||
key={index}
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
background: 'var(--color-bg-inset)',
|
||||
border: '1px solid var(--color-border-default)',
|
||||
borderRadius: '3px',
|
||||
fontSize: '10px',
|
||||
color: 'var(--color-text-primary)',
|
||||
fontFamily: 'var(--font-family-mono)'
|
||||
}}
|
||||
>
|
||||
{typeof item === 'object' ? JSON.stringify(item) : String(item)}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const valueType = typeof value;
|
||||
|
||||
if (valueType === 'boolean') {
|
||||
return (
|
||||
<div key={key} className="property-field property-field-boolean">
|
||||
<label className="property-label">{key}</label>
|
||||
<div className={`property-toggle ${value ? 'property-toggle-on' : 'property-toggle-off'} property-toggle-readonly`}>
|
||||
<span className="property-toggle-thumb" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueType === 'number') {
|
||||
return (
|
||||
<div key={key} className="property-field">
|
||||
<label className="property-label">{key}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueType === 'string') {
|
||||
return (
|
||||
<div key={key} className="property-field">
|
||||
<label className="property-label">{key}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="property-input property-input-text"
|
||||
value={value}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueType === 'object' && value.r !== undefined && value.g !== undefined && value.b !== undefined) {
|
||||
const r = Math.round(value.r * 255);
|
||||
const g = Math.round(value.g * 255);
|
||||
const b = Math.round(value.b * 255);
|
||||
const a = value.a !== undefined ? value.a : 1;
|
||||
const hexColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
|
||||
return (
|
||||
<div key={key} className="property-field">
|
||||
<label className="property-label">{key}</label>
|
||||
<div className="property-color-wrapper">
|
||||
<div className="property-color-preview" style={{ backgroundColor: hexColor, opacity: a }} />
|
||||
<input
|
||||
type="text"
|
||||
className="property-input property-input-color-text"
|
||||
value={`${hexColor.toUpperCase()} (${a.toFixed(2)})`}
|
||||
disabled
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueType === 'object' && value.minX !== undefined && value.maxX !== undefined && value.minY !== undefined && value.maxY !== undefined) {
|
||||
return (
|
||||
<div key={key} className="property-field" style={{ flexDirection: 'column', alignItems: 'stretch' }}>
|
||||
<label className="property-label" style={{ flex: 'none', marginBottom: '4px' }}>{key}</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div className="property-vector-compact">
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-x">X</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value.minX}
|
||||
disabled
|
||||
placeholder="Min"
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-x">X</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value.maxX}
|
||||
disabled
|
||||
placeholder="Max"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="property-vector-compact">
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value.minY}
|
||||
disabled
|
||||
placeholder="Min"
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value.maxY}
|
||||
disabled
|
||||
placeholder="Max"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueType === 'object' && value.x !== undefined && value.y !== undefined) {
|
||||
if (value.z !== undefined) {
|
||||
return (
|
||||
<div key={key} className="property-field">
|
||||
<label className="property-label">{key}</label>
|
||||
<div className="property-vector-compact">
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-x">X</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value.x}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value.y}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-z">Z</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value.z}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={key} className="property-field">
|
||||
<label className="property-label">{key}</label>
|
||||
<div className="property-vector-compact">
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-x">X</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value.x}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value.y}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} className="property-field">
|
||||
<label className="property-label">{key}</label>
|
||||
<span className="property-value-text">{JSON.stringify(value)}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!selectedEntity && !remoteEntity) {
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-header">
|
||||
<FileSearch size={16} className="inspector-header-icon" />
|
||||
<h3>Inspector</h3>
|
||||
</div>
|
||||
<div className="inspector-content">
|
||||
<div className="empty-state">
|
||||
<FileSearch size={48} strokeWidth={1.5} className="empty-icon" />
|
||||
<div className="empty-title">No entity selected</div>
|
||||
<div className="empty-hint">Select an entity from the hierarchy</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 显示远程实体
|
||||
if (remoteEntity) {
|
||||
const displayData = remoteEntityDetails || remoteEntity;
|
||||
const hasDetailedComponents = remoteEntityDetails && remoteEntityDetails.components && remoteEntityDetails.components.length > 0;
|
||||
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-header">
|
||||
<FileSearch size={16} className="inspector-header-icon" />
|
||||
<h3>Inspector</h3>
|
||||
</div>
|
||||
<div className="inspector-content scrollable">
|
||||
<div className="inspector-section">
|
||||
<div className="section-header">
|
||||
<Settings size={12} className="section-icon" />
|
||||
<span>Entity Info (Remote)</span>
|
||||
</div>
|
||||
<div className="section-content">
|
||||
<div className="info-row">
|
||||
<span className="info-label">ID:</span>
|
||||
<span className="info-value">{displayData.id}</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="info-label">Name:</span>
|
||||
<span className="info-value">{displayData.name}</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="info-label">Enabled:</span>
|
||||
<span className="info-value">{displayData.enabled ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
{displayData.scene && (
|
||||
<div className="info-row">
|
||||
<span className="info-label">Scene:</span>
|
||||
<span className="info-value">{displayData.scene}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inspector-section">
|
||||
<div className="section-header">
|
||||
<Settings size={12} className="section-icon" />
|
||||
<span>Components ({displayData.componentCount})</span>
|
||||
</div>
|
||||
<div className="section-content">
|
||||
{hasDetailedComponents ? (
|
||||
<ul className="component-list">
|
||||
{remoteEntityDetails!.components.map((component: any, index: number) => {
|
||||
const isExpanded = expandedComponents.has(index);
|
||||
return (
|
||||
<li key={index} className={`component-item ${isExpanded ? 'expanded' : ''}`}>
|
||||
<div className="component-header" onClick={() => toggleComponentExpanded(index)}>
|
||||
<button
|
||||
className="component-expand-btn"
|
||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<Settings size={14} className="component-icon" />
|
||||
<span className="component-name">{component.typeName}</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="component-properties animate-slideDown">
|
||||
<div className="property-inspector">
|
||||
{Object.entries(component.properties).map(([key, value]) =>
|
||||
renderRemoteProperty(key, value)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : displayData.componentTypes && displayData.componentTypes.length > 0 ? (
|
||||
<ul className="component-list">
|
||||
{displayData.componentTypes.map((componentType: string, index: number) => (
|
||||
<li key={index} className="component-item">
|
||||
<div className="component-header">
|
||||
<Settings size={14} className="component-icon" />
|
||||
<span className="component-name">{componentType}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="empty-state-small">No components</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const components = selectedEntity!.components;
|
||||
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-header">
|
||||
<FileSearch size={16} className="inspector-header-icon" />
|
||||
<h3>Inspector</h3>
|
||||
</div>
|
||||
<div className="inspector-content scrollable">
|
||||
<div className="inspector-section">
|
||||
<div className="section-header">
|
||||
<Settings size={12} className="section-icon" />
|
||||
<span>Entity Info</span>
|
||||
</div>
|
||||
<div className="section-content">
|
||||
<div className="info-row">
|
||||
<span className="info-label">ID:</span>
|
||||
<span className="info-value">{selectedEntity!.id}</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="info-label">Name:</span>
|
||||
<span className="info-value">Entity {selectedEntity!.id}</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="info-label">Enabled:</span>
|
||||
<span className="info-value">{selectedEntity!.enabled ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inspector-section">
|
||||
<div className="section-header">
|
||||
<Settings size={12} className="section-icon" />
|
||||
<span>Components ({components.length})</span>
|
||||
</div>
|
||||
<div className="section-content">
|
||||
{components.length === 0 ? (
|
||||
<div className="empty-state-small">No components</div>
|
||||
) : (
|
||||
<ul className="component-list" key={componentVersion}>
|
||||
{components.map((component, index) => {
|
||||
const isExpanded = expandedComponents.has(index);
|
||||
return (
|
||||
<li key={index} className={`component-item ${isExpanded ? 'expanded' : ''}`}>
|
||||
<div className="component-header" onClick={() => toggleComponentExpanded(index)}>
|
||||
<button
|
||||
className="component-expand-btn"
|
||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<Settings size={14} className="component-icon" />
|
||||
<span className="component-name">{component.constructor.name}</span>
|
||||
<button
|
||||
className="remove-component-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveComponent(index);
|
||||
}}
|
||||
title="Remove Component"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="component-properties animate-slideDown">
|
||||
<PropertyInspector
|
||||
key={`${index}-${componentVersion}`}
|
||||
component={component}
|
||||
onChange={(propertyName, value) => handlePropertyChange(component, propertyName, value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Activity, Cpu, Layers, Package, Wifi, WifiOff, Maximize2, Pause, Play, BarChart3 } from 'lucide-react';
|
||||
import type { ProfilerData } from '../services/tokens';
|
||||
import { SettingsService } from '../services/SettingsService';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { getProfilerService } from '../services/getService';
|
||||
import '../styles/ProfilerDockPanel.css';
|
||||
|
||||
export function ProfilerDockPanel() {
|
||||
const [profilerData, setProfilerData] = useState<ProfilerData | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isServerRunning, setIsServerRunning] = useState(false);
|
||||
const [port, setPort] = useState('8080');
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const settings = SettingsService.getInstance();
|
||||
setPort(settings.get('profiler.port', '8080'));
|
||||
|
||||
const handleSettingsChange = ((event: CustomEvent) => {
|
||||
const newPort = event.detail['profiler.port'];
|
||||
if (newPort) {
|
||||
setPort(newPort);
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
window.addEventListener('settings:changed', handleSettingsChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('settings:changed', handleSettingsChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const profilerService = getProfilerService();
|
||||
|
||||
if (!profilerService) {
|
||||
console.warn('[ProfilerDockPanel] ProfilerService not available - plugin may be disabled');
|
||||
setIsServerRunning(false);
|
||||
setIsConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 订阅数据更新
|
||||
const unsubscribe = profilerService.subscribe((data: ProfilerData) => {
|
||||
if (!isPaused) {
|
||||
setProfilerData(data);
|
||||
}
|
||||
});
|
||||
|
||||
// 定期检查连接状态
|
||||
const checkStatus = () => {
|
||||
setIsConnected(profilerService.isConnected());
|
||||
setIsServerRunning(profilerService.isServerActive());
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
const interval = setInterval(checkStatus, 1000);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [isPaused]);
|
||||
|
||||
const fps = profilerData?.fps || 0;
|
||||
const totalFrameTime = profilerData?.totalFrameTime || 0;
|
||||
const systems = (profilerData?.systems || []).slice(0, 5); // Only show top 5 systems in dock panel
|
||||
const entityCount = profilerData?.entityCount || 0;
|
||||
const componentCount = profilerData?.componentCount || 0;
|
||||
const targetFrameTime = 16.67;
|
||||
|
||||
const handleOpenDetails = () => {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('ui:openWindow', { windowId: 'profiler' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAdvancedProfiler = () => {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('ui:openWindow', { windowId: 'advancedProfiler' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePause = () => {
|
||||
setIsPaused(!isPaused);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="profiler-dock-panel">
|
||||
<div className="profiler-dock-header">
|
||||
<h3>Performance Monitor</h3>
|
||||
<div className="profiler-dock-header-actions">
|
||||
{isConnected && (
|
||||
<>
|
||||
<button
|
||||
className="profiler-dock-pause-btn"
|
||||
onClick={handleTogglePause}
|
||||
title={isPaused ? 'Resume data updates' : 'Pause data updates'}
|
||||
>
|
||||
{isPaused ? <Play size={14} /> : <Pause size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="profiler-dock-details-btn"
|
||||
onClick={handleOpenAdvancedProfiler}
|
||||
title="Open advanced profiler"
|
||||
>
|
||||
<BarChart3 size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="profiler-dock-details-btn"
|
||||
onClick={handleOpenDetails}
|
||||
title="Open detailed profiler"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="profiler-dock-status">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Wifi size={12} />
|
||||
<span className="status-text connected">Connected</span>
|
||||
</>
|
||||
) : isServerRunning ? (
|
||||
<>
|
||||
<WifiOff size={12} />
|
||||
<span className="status-text waiting">Waiting...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff size={12} />
|
||||
<span className="status-text disconnected">Server Off</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isServerRunning ? (
|
||||
<div className="profiler-dock-empty">
|
||||
<Cpu size={32} />
|
||||
<p>Profiler server not running</p>
|
||||
<p className="hint">Open Profiler window and connect to start monitoring</p>
|
||||
</div>
|
||||
) : !isConnected ? (
|
||||
<div className="profiler-dock-empty">
|
||||
<Activity size={32} />
|
||||
<p>Waiting for game connection...</p>
|
||||
<p className="hint">Connect to: <code>ws://localhost:{port}</code></p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="profiler-dock-content">
|
||||
<div className="profiler-dock-stats">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">
|
||||
<Activity size={16} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<div className="stat-label">FPS</div>
|
||||
<div className={`stat-value ${fps < 55 ? 'warning' : ''}`}>{fps}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">
|
||||
<Cpu size={16} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<div className="stat-label">Frame Time</div>
|
||||
<div className={`stat-value ${totalFrameTime > targetFrameTime ? 'warning' : ''}`}>
|
||||
{totalFrameTime.toFixed(1)}ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">
|
||||
<Layers size={16} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<div className="stat-label">Entities</div>
|
||||
<div className="stat-value">{entityCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">
|
||||
<Package size={16} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<div className="stat-label">Components</div>
|
||||
<div className="stat-value">{componentCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{systems.length > 0 && (
|
||||
<div className="profiler-dock-systems">
|
||||
<h4>Top Systems</h4>
|
||||
<div className="systems-list">
|
||||
{systems.map((system) => (
|
||||
<div key={system.name} className="system-item">
|
||||
<div className="system-item-header">
|
||||
<span className="system-item-name">{system.name}</span>
|
||||
<span className="system-item-time">
|
||||
{system.executionTime.toFixed(2)}ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="system-item-bar">
|
||||
<div
|
||||
className="system-item-bar-fill"
|
||||
style={{
|
||||
width: `${Math.min(system.percentage, 100)}%`,
|
||||
backgroundColor: system.executionTime > targetFrameTime
|
||||
? 'var(--color-danger)'
|
||||
: system.executionTime > targetFrameTime * 0.5
|
||||
? 'var(--color-warning)'
|
||||
: 'var(--color-success)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="system-item-footer">
|
||||
<span className="system-item-percentage">{system.percentage.toFixed(1)}%</span>
|
||||
{system.entityCount > 0 && (
|
||||
<span className="system-item-entities">{system.entityCount} entities</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { Activity, BarChart3, Clock, Cpu, RefreshCw, Pause, Play } from 'lucide-react';
|
||||
import '../styles/ProfilerPanel.css';
|
||||
|
||||
interface SystemPerformanceData {
|
||||
name: string;
|
||||
executionTime: number;
|
||||
entityCount: number;
|
||||
averageTime: number;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export function ProfilerPanel() {
|
||||
const [systems, setSystems] = useState<SystemPerformanceData[]>([]);
|
||||
const [totalFrameTime, setTotalFrameTime] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [sortBy, setSortBy] = useState<'time' | 'average' | 'name'>('time');
|
||||
const animationRef = useRef<number>();
|
||||
|
||||
useEffect(() => {
|
||||
const updateProfilerData = () => {
|
||||
if (isPaused) {
|
||||
animationRef.current = requestAnimationFrame(updateProfilerData);
|
||||
return;
|
||||
}
|
||||
|
||||
const performanceMonitor = Core.performanceMonitor;
|
||||
if (!performanceMonitor?.isEnabled) {
|
||||
animationRef.current = requestAnimationFrame(updateProfilerData);
|
||||
return;
|
||||
}
|
||||
const systemDataMap = performanceMonitor.getAllSystemData();
|
||||
const systemStatsMap = performanceMonitor.getAllSystemStats();
|
||||
|
||||
const systemsData: SystemPerformanceData[] = [];
|
||||
let total = 0;
|
||||
|
||||
for (const [name, data] of systemDataMap.entries()) {
|
||||
const stats = systemStatsMap.get(name);
|
||||
if (stats) {
|
||||
systemsData.push({
|
||||
name,
|
||||
executionTime: data.executionTime,
|
||||
entityCount: data.entityCount,
|
||||
averageTime: stats.averageTime,
|
||||
minTime: stats.minTime,
|
||||
maxTime: stats.maxTime,
|
||||
percentage: 0
|
||||
});
|
||||
total += data.executionTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate percentages
|
||||
systemsData.forEach((system) => {
|
||||
system.percentage = total > 0 ? (system.executionTime / total) * 100 : 0;
|
||||
});
|
||||
|
||||
// Sort systems
|
||||
systemsData.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'time':
|
||||
return b.executionTime - a.executionTime;
|
||||
case 'average':
|
||||
return b.averageTime - a.averageTime;
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
setSystems(systemsData);
|
||||
setTotalFrameTime(total);
|
||||
|
||||
animationRef.current = requestAnimationFrame(updateProfilerData);
|
||||
};
|
||||
|
||||
animationRef.current = requestAnimationFrame(updateProfilerData);
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPaused, sortBy]);
|
||||
|
||||
const handleReset = () => {
|
||||
Core.performanceMonitor?.reset();
|
||||
};
|
||||
|
||||
const fps = totalFrameTime > 0 ? Math.round(1000 / totalFrameTime) : 0;
|
||||
const targetFrameTime = 16.67; // 60 FPS
|
||||
const isOverBudget = totalFrameTime > targetFrameTime;
|
||||
|
||||
return (
|
||||
<div className="profiler-panel">
|
||||
<div className="profiler-toolbar">
|
||||
<div className="profiler-toolbar-left">
|
||||
<div className="profiler-stats-summary">
|
||||
<div className="summary-item">
|
||||
<Clock size={14} />
|
||||
<span className="summary-label">Frame:</span>
|
||||
<span className={`summary-value ${isOverBudget ? 'over-budget' : ''}`}>
|
||||
{totalFrameTime.toFixed(2)}ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<Activity size={14} />
|
||||
<span className="summary-label">FPS:</span>
|
||||
<span className={`summary-value ${fps < 55 ? 'low-fps' : ''}`}>{fps}</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<BarChart3 size={14} />
|
||||
<span className="summary-label">Systems:</span>
|
||||
<span className="summary-value">{systems.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profiler-toolbar-right">
|
||||
<select
|
||||
className="profiler-sort"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
>
|
||||
<option value="time">Sort by Time</option>
|
||||
<option value="average">Sort by Average</option>
|
||||
<option value="name">Sort by Name</option>
|
||||
</select>
|
||||
<button
|
||||
className="profiler-btn"
|
||||
onClick={() => setIsPaused(!isPaused)}
|
||||
title={isPaused ? 'Resume' : 'Pause'}
|
||||
>
|
||||
{isPaused ? <Play size={14} /> : <Pause size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="profiler-btn"
|
||||
onClick={handleReset}
|
||||
title="Reset Statistics"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profiler-content">
|
||||
{systems.length === 0 ? (
|
||||
<div className="profiler-empty">
|
||||
<Cpu size={48} />
|
||||
<p>No performance data available</p>
|
||||
<p className="profiler-empty-hint">
|
||||
Make sure Core debug mode is enabled and systems are running
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="profiler-systems">
|
||||
{systems.map((system, index) => (
|
||||
<div key={system.name} className="system-row">
|
||||
<div className="system-header">
|
||||
<div className="system-info">
|
||||
<span className="system-rank">#{index + 1}</span>
|
||||
<span className="system-name">{system.name}</span>
|
||||
{system.entityCount > 0 && (
|
||||
<span className="system-entities">
|
||||
({system.entityCount} entities)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="system-metrics">
|
||||
<span className="metric-time">{system.executionTime.toFixed(2)}ms</span>
|
||||
<span className="metric-percentage">{system.percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="system-bar">
|
||||
<div
|
||||
className="system-bar-fill"
|
||||
style={{
|
||||
width: `${Math.min(system.percentage, 100)}%`,
|
||||
backgroundColor: system.executionTime > targetFrameTime
|
||||
? 'var(--color-danger)'
|
||||
: system.executionTime > targetFrameTime * 0.5
|
||||
? 'var(--color-warning)'
|
||||
: 'var(--color-success)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="system-stats">
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Avg:</span>
|
||||
<span className="stat-value">{system.averageTime.toFixed(2)}ms</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Min:</span>
|
||||
<span className="stat-value">{system.minTime.toFixed(2)}ms</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Max:</span>
|
||||
<span className="stat-value">{system.maxTime.toFixed(2)}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="profiler-footer">
|
||||
<div className="profiler-legend">
|
||||
<div className="legend-item">
|
||||
<div className="legend-color" style={{ background: 'var(--color-success)' }} />
|
||||
<span>Good (<8ms)</span>
|
||||
</div>
|
||||
<div className="legend-item">
|
||||
<div className="legend-color" style={{ background: 'var(--color-warning)' }} />
|
||||
<span>Warning (8-16ms)</span>
|
||||
</div>
|
||||
<div className="legend-item">
|
||||
<div className="legend-color" style={{ background: 'var(--color-danger)' }} />
|
||||
<span>Critical (>16ms)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,589 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { Activity, BarChart3, Clock, Cpu, RefreshCw, Pause, Play, X, Wifi, WifiOff, Server, Search, Table2, TreePine } from 'lucide-react';
|
||||
import { ProfilerService } from '../services/ProfilerService';
|
||||
import { SettingsService } from '../services/SettingsService';
|
||||
import { getProfilerService } from '../services/getService';
|
||||
import '../styles/ProfilerWindow.css';
|
||||
|
||||
interface SystemPerformanceData {
|
||||
name: string;
|
||||
executionTime: number;
|
||||
entityCount: number;
|
||||
averageTime: number;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
percentage: number;
|
||||
level: number;
|
||||
children?: SystemPerformanceData[];
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
interface ProfilerWindowProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type DataSource = 'local' | 'remote';
|
||||
|
||||
export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
|
||||
const [systems, setSystems] = useState<SystemPerformanceData[]>([]);
|
||||
const [totalFrameTime, setTotalFrameTime] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [sortBy] = useState<'time' | 'average' | 'name'>('time');
|
||||
const [dataSource, setDataSource] = useState<DataSource>('local');
|
||||
const [viewMode, setViewMode] = useState<'tree' | 'table'>('table');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isServerRunning, setIsServerRunning] = useState(false);
|
||||
const [port, setPort] = useState('8080');
|
||||
const animationRef = useRef<number>();
|
||||
const frameTimesRef = useRef<number[]>([]);
|
||||
const lastFpsRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const settings = SettingsService.getInstance();
|
||||
setPort(settings.get('profiler.port', '8080'));
|
||||
|
||||
const handleSettingsChange = ((event: CustomEvent) => {
|
||||
const newPort = event.detail['profiler.port'];
|
||||
if (newPort) {
|
||||
setPort(newPort);
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
window.addEventListener('settings:changed', handleSettingsChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('settings:changed', handleSettingsChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Check ProfilerService connection status
|
||||
useEffect(() => {
|
||||
const profilerService = getProfilerService();
|
||||
|
||||
if (!profilerService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkStatus = () => {
|
||||
setIsConnected(profilerService.isConnected());
|
||||
setIsServerRunning(profilerService.isServerActive());
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
const interval = setInterval(checkStatus, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const buildSystemTree = (flatSystems: Map<string, any>, statsMap: Map<string, any>): SystemPerformanceData[] => {
|
||||
const coreUpdate = flatSystems.get('Core.update');
|
||||
const servicesUpdate = flatSystems.get('Services.update');
|
||||
|
||||
if (!coreUpdate) return [];
|
||||
|
||||
const coreStats = statsMap.get('Core.update');
|
||||
const coreNode: SystemPerformanceData = {
|
||||
name: 'Core.update',
|
||||
executionTime: coreUpdate.executionTime,
|
||||
entityCount: 0,
|
||||
averageTime: coreStats?.averageTime || 0,
|
||||
minTime: coreStats?.minTime || 0,
|
||||
maxTime: coreStats?.maxTime || 0,
|
||||
percentage: 100,
|
||||
level: 0,
|
||||
children: [],
|
||||
isExpanded: true
|
||||
};
|
||||
|
||||
if (servicesUpdate) {
|
||||
const servicesStats = statsMap.get('Services.update');
|
||||
coreNode.children!.push({
|
||||
name: 'Services.update',
|
||||
executionTime: servicesUpdate.executionTime,
|
||||
entityCount: 0,
|
||||
averageTime: servicesStats?.averageTime || 0,
|
||||
minTime: servicesStats?.minTime || 0,
|
||||
maxTime: servicesStats?.maxTime || 0,
|
||||
percentage: coreUpdate.executionTime > 0
|
||||
? (servicesUpdate.executionTime / coreUpdate.executionTime) * 100
|
||||
: 0,
|
||||
level: 1,
|
||||
isExpanded: false
|
||||
});
|
||||
}
|
||||
|
||||
const sceneSystems: SystemPerformanceData[] = [];
|
||||
|
||||
for (const [name, data] of flatSystems.entries()) {
|
||||
if (name !== 'Core.update' && name !== 'Services.update') {
|
||||
const stats = statsMap.get(name);
|
||||
if (stats) {
|
||||
sceneSystems.push({
|
||||
name,
|
||||
executionTime: data.executionTime,
|
||||
entityCount: data.entityCount,
|
||||
averageTime: stats.averageTime,
|
||||
minTime: stats.minTime,
|
||||
maxTime: stats.maxTime,
|
||||
percentage: 0,
|
||||
level: 1,
|
||||
isExpanded: false
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sceneSystems.forEach((system) => {
|
||||
system.percentage = coreUpdate.executionTime > 0
|
||||
? (system.executionTime / coreUpdate.executionTime) * 100
|
||||
: 0;
|
||||
});
|
||||
|
||||
sceneSystems.sort((a, b) => b.executionTime - a.executionTime);
|
||||
coreNode.children!.push(...sceneSystems);
|
||||
|
||||
return [coreNode];
|
||||
};
|
||||
|
||||
// Subscribe to local performance data
|
||||
useEffect(() => {
|
||||
if (dataSource !== 'local') return;
|
||||
|
||||
const updateProfilerData = () => {
|
||||
if (isPaused) {
|
||||
animationRef.current = requestAnimationFrame(updateProfilerData);
|
||||
return;
|
||||
}
|
||||
|
||||
const performanceMonitor = Core.performanceMonitor;
|
||||
if (!performanceMonitor?.isEnabled) {
|
||||
animationRef.current = requestAnimationFrame(updateProfilerData);
|
||||
return;
|
||||
}
|
||||
const systemDataMap = performanceMonitor.getAllSystemData();
|
||||
const systemStatsMap = performanceMonitor.getAllSystemStats();
|
||||
|
||||
const tree = buildSystemTree(systemDataMap, systemStatsMap);
|
||||
const coreData = systemDataMap.get('Core.update');
|
||||
|
||||
setSystems(tree);
|
||||
setTotalFrameTime(coreData?.executionTime || 0);
|
||||
|
||||
animationRef.current = requestAnimationFrame(updateProfilerData);
|
||||
};
|
||||
|
||||
animationRef.current = requestAnimationFrame(updateProfilerData);
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPaused, sortBy, dataSource]);
|
||||
|
||||
// Subscribe to remote performance data from ProfilerService
|
||||
useEffect(() => {
|
||||
if (dataSource !== 'remote') return;
|
||||
|
||||
const profilerService = getProfilerService();
|
||||
|
||||
if (!profilerService) {
|
||||
console.warn('[ProfilerWindow] ProfilerService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = profilerService.subscribe((data) => {
|
||||
if (isPaused) return;
|
||||
|
||||
handleRemoteDebugData({
|
||||
performance: {
|
||||
frameTime: data.totalFrameTime,
|
||||
systemPerformance: data.systems.map((sys) => ({
|
||||
systemName: sys.name,
|
||||
lastExecutionTime: sys.executionTime,
|
||||
averageTime: sys.averageTime,
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
entityCount: sys.entityCount,
|
||||
percentage: sys.percentage
|
||||
}))
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [dataSource, isPaused]);
|
||||
|
||||
const handleReset = () => {
|
||||
if (dataSource === 'local') {
|
||||
Core.performanceMonitor?.reset();
|
||||
} else {
|
||||
// Reset remote data
|
||||
setSystems([]);
|
||||
setTotalFrameTime(0);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleRemoteDebugData = (debugData: any) => {
|
||||
if (isPaused) return;
|
||||
|
||||
const performance = debugData.performance;
|
||||
if (!performance) return;
|
||||
|
||||
if (!performance.systemPerformance || !Array.isArray(performance.systemPerformance)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const flatSystemsMap = new Map();
|
||||
const statsMap = new Map();
|
||||
|
||||
for (const system of performance.systemPerformance) {
|
||||
flatSystemsMap.set(system.systemName, {
|
||||
executionTime: system.lastExecutionTime || system.averageTime || 0,
|
||||
entityCount: system.entityCount || 0
|
||||
});
|
||||
|
||||
statsMap.set(system.systemName, {
|
||||
averageTime: system.averageTime || 0,
|
||||
minTime: system.minTime || 0,
|
||||
maxTime: system.maxTime || 0
|
||||
});
|
||||
}
|
||||
|
||||
const tree = buildSystemTree(flatSystemsMap, statsMap);
|
||||
setSystems(tree);
|
||||
setTotalFrameTime(performance.frameTime || 0);
|
||||
};
|
||||
|
||||
const handleDataSourceChange = (newSource: DataSource) => {
|
||||
if (newSource === 'remote' && dataSource === 'local') {
|
||||
// Switching to remote
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
}
|
||||
setDataSource(newSource);
|
||||
setSystems([]);
|
||||
setTotalFrameTime(0);
|
||||
};
|
||||
|
||||
const toggleExpand = (systemName: string) => {
|
||||
const toggleNode = (nodes: SystemPerformanceData[]): SystemPerformanceData[] => {
|
||||
return nodes.map((node) => {
|
||||
if (node.name === systemName) {
|
||||
return { ...node, isExpanded: !node.isExpanded };
|
||||
}
|
||||
if (node.children) {
|
||||
return { ...node, children: toggleNode(node.children) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
setSystems(toggleNode(systems));
|
||||
};
|
||||
|
||||
const flattenTree = (nodes: SystemPerformanceData[]): SystemPerformanceData[] => {
|
||||
const result: SystemPerformanceData[] = [];
|
||||
for (const node of nodes) {
|
||||
result.push(node);
|
||||
if (node.isExpanded && node.children) {
|
||||
result.push(...flattenTree(node.children));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Calculate FPS using rolling average for stability
|
||||
// 使用滑动平均计算 FPS 以保持稳定
|
||||
const calculateFps = () => {
|
||||
// Add any positive frame time
|
||||
// 添加任何正数的帧时间
|
||||
if (totalFrameTime > 0) {
|
||||
frameTimesRef.current.push(totalFrameTime);
|
||||
// Keep last 60 samples
|
||||
if (frameTimesRef.current.length > 60) {
|
||||
frameTimesRef.current.shift();
|
||||
}
|
||||
}
|
||||
|
||||
if (frameTimesRef.current.length > 0) {
|
||||
const avgFrameTime = frameTimesRef.current.reduce((a, b) => a + b, 0) / frameTimesRef.current.length;
|
||||
// Cap FPS between 0-999, and ensure avgFrameTime is reasonable
|
||||
if (avgFrameTime > 0.01) {
|
||||
lastFpsRef.current = Math.min(999, Math.round(1000 / avgFrameTime));
|
||||
}
|
||||
}
|
||||
return lastFpsRef.current;
|
||||
};
|
||||
const fps = calculateFps();
|
||||
const targetFrameTime = 16.67;
|
||||
const isOverBudget = totalFrameTime > targetFrameTime;
|
||||
|
||||
let displaySystems = viewMode === 'tree' ? flattenTree(systems) : systems;
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
if (viewMode === 'tree') {
|
||||
displaySystems = displaySystems.filter((sys) =>
|
||||
sys.name.toLowerCase().includes(query)
|
||||
);
|
||||
} else {
|
||||
// For table view, flatten and filter
|
||||
const flatList: SystemPerformanceData[] = [];
|
||||
const flatten = (nodes: SystemPerformanceData[]) => {
|
||||
for (const node of nodes) {
|
||||
flatList.push(node);
|
||||
if (node.children) flatten(node.children);
|
||||
}
|
||||
};
|
||||
flatten(systems);
|
||||
displaySystems = flatList.filter((sys) =>
|
||||
sys.name.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
} else if (viewMode === 'table') {
|
||||
// For table view without search, flatten all
|
||||
const flatList: SystemPerformanceData[] = [];
|
||||
const flatten = (nodes: SystemPerformanceData[]) => {
|
||||
for (const node of nodes) {
|
||||
flatList.push(node);
|
||||
if (node.children) flatten(node.children);
|
||||
}
|
||||
};
|
||||
flatten(systems);
|
||||
displaySystems = flatList;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="profiler-window-overlay" onClick={onClose}>
|
||||
<div className="profiler-window" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="profiler-window-header">
|
||||
<div className="profiler-window-title">
|
||||
<BarChart3 size={20} />
|
||||
<h2>Performance Profiler</h2>
|
||||
{isPaused && (
|
||||
<span className="paused-indicator">PAUSED</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="profiler-window-close" onClick={onClose} title="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="profiler-window-toolbar">
|
||||
<div className="profiler-toolbar-left">
|
||||
<div className="profiler-mode-switch">
|
||||
<button
|
||||
className={`mode-btn ${dataSource === 'local' ? 'active' : ''}`}
|
||||
onClick={() => handleDataSourceChange('local')}
|
||||
title="Local Core Instance"
|
||||
>
|
||||
<Cpu size={14} />
|
||||
<span>Local</span>
|
||||
</button>
|
||||
<button
|
||||
className={`mode-btn ${dataSource === 'remote' ? 'active' : ''}`}
|
||||
onClick={() => handleDataSourceChange('remote')}
|
||||
title="Remote Game Connection"
|
||||
>
|
||||
<Server size={14} />
|
||||
<span>Remote</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dataSource === 'remote' && (
|
||||
<div className="profiler-connection">
|
||||
<div className="connection-port-display">
|
||||
<Server size={14} />
|
||||
<span>ws://localhost:{port}</span>
|
||||
</div>
|
||||
{isConnected ? (
|
||||
<div className="connection-status-indicator connected">
|
||||
<Wifi size={14} />
|
||||
<span>Connected</span>
|
||||
</div>
|
||||
) : isServerRunning ? (
|
||||
<div className="connection-status-indicator waiting">
|
||||
<WifiOff size={14} />
|
||||
<span>Waiting for game...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="connection-status-indicator disconnected">
|
||||
<WifiOff size={14} />
|
||||
<span>Server Off</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dataSource === 'local' && (
|
||||
<div className="profiler-stats-summary">
|
||||
<div className="summary-item">
|
||||
<Clock size={14} />
|
||||
<span className="summary-label">Frame:</span>
|
||||
<span className={`summary-value ${isOverBudget ? 'over-budget' : ''}`}>
|
||||
{totalFrameTime.toFixed(2)}ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<Activity size={14} />
|
||||
<span className="summary-label">FPS:</span>
|
||||
<span className={`summary-value ${fps < 55 ? 'low-fps' : ''}`}>{fps}</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<BarChart3 size={14} />
|
||||
<span className="summary-label">Systems:</span>
|
||||
<span className="summary-value">{systems.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="profiler-toolbar-right">
|
||||
<div className="profiler-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search systems..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="view-mode-switch">
|
||||
<button
|
||||
className={`view-mode-btn ${viewMode === 'table' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('table')}
|
||||
title="Table View"
|
||||
>
|
||||
<Table2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`view-mode-btn ${viewMode === 'tree' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('tree')}
|
||||
title="Tree View"
|
||||
>
|
||||
<TreePine size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className={`profiler-btn ${isPaused ? 'paused' : ''}`}
|
||||
onClick={() => setIsPaused(!isPaused)}
|
||||
title={isPaused ? 'Resume' : 'Pause'}
|
||||
>
|
||||
{isPaused ? <Play size={14} /> : <Pause size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="profiler-btn"
|
||||
onClick={handleReset}
|
||||
title="Reset Statistics"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profiler-window-content">
|
||||
{displaySystems.length === 0 ? (
|
||||
<div className="profiler-empty">
|
||||
<Cpu size={48} />
|
||||
<p>No performance data available</p>
|
||||
<p className="profiler-empty-hint">
|
||||
{searchQuery ? 'No systems match your search' : 'Make sure Core debug mode is enabled and systems are running'}
|
||||
</p>
|
||||
</div>
|
||||
) : viewMode === 'table' ? (
|
||||
<table className="profiler-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-name">System Name</th>
|
||||
<th className="col-time">Current</th>
|
||||
<th className="col-time">Average</th>
|
||||
<th className="col-time">Min</th>
|
||||
<th className="col-time">Max</th>
|
||||
<th className="col-percent">%</th>
|
||||
<th className="col-entities">Entities</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displaySystems.map((system) => (
|
||||
<tr key={system.name} className={`level-${system.level}`}>
|
||||
<td className="col-name">
|
||||
<span className="system-name-cell" style={{ paddingLeft: `${system.level * 16}px` }}>
|
||||
{system.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="col-time">
|
||||
<span className={`time-value ${system.executionTime > targetFrameTime ? 'critical' : system.executionTime > targetFrameTime * 0.5 ? 'warning' : ''}`}>
|
||||
{system.executionTime.toFixed(2)}ms
|
||||
</span>
|
||||
</td>
|
||||
<td className="col-time">{system.averageTime.toFixed(2)}ms</td>
|
||||
<td className="col-time">{system.minTime.toFixed(2)}ms</td>
|
||||
<td className="col-time">{system.maxTime.toFixed(2)}ms</td>
|
||||
<td className="col-percent">{system.percentage.toFixed(1)}%</td>
|
||||
<td className="col-entities">{system.entityCount || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="profiler-tree">
|
||||
{displaySystems.map((system) => (
|
||||
<div key={system.name} className={`tree-row level-${system.level}`}>
|
||||
<div className="tree-row-header">
|
||||
<div className="tree-row-left">
|
||||
{system.children && system.children.length > 0 && (
|
||||
<button
|
||||
className="expand-btn"
|
||||
onClick={() => toggleExpand(system.name)}
|
||||
>
|
||||
{system.isExpanded ? '▼' : '▶'}
|
||||
</button>
|
||||
)}
|
||||
<span className="system-name">{system.name}</span>
|
||||
{system.entityCount > 0 && (
|
||||
<span className="system-entities">({system.entityCount})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="tree-row-right">
|
||||
<span className={`time-value ${system.executionTime > targetFrameTime ? 'critical' : system.executionTime > targetFrameTime * 0.5 ? 'warning' : ''}`}>
|
||||
{system.executionTime.toFixed(2)}ms
|
||||
</span>
|
||||
<span className="percentage-badge">{system.percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tree-row-stats">
|
||||
<span>Avg: {system.averageTime.toFixed(2)}ms</span>
|
||||
<span>Min: {system.minTime.toFixed(2)}ms</span>
|
||||
<span>Max: {system.maxTime.toFixed(2)}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="profiler-window-footer">
|
||||
<div className="profiler-legend">
|
||||
<div className="legend-item">
|
||||
<div className="legend-color" style={{ background: 'var(--color-success)' }} />
|
||||
<span>Good (<8ms)</span>
|
||||
</div>
|
||||
<div className="legend-item">
|
||||
<div className="legend-color" style={{ background: 'var(--color-warning)' }} />
|
||||
<span>Warning (8-16ms)</span>
|
||||
</div>
|
||||
<div className="legend-item">
|
||||
<div className="legend-color" style={{ background: 'var(--color-danger)' }} />
|
||||
<span>Critical (>16ms)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Inspector Components
|
||||
* Inspector 组件导出
|
||||
*/
|
||||
|
||||
// 主组件 | Main components
|
||||
export * from './InspectorPanel';
|
||||
export * from './EntityInspectorPanel';
|
||||
export * from './ComponentPropertyEditor';
|
||||
|
||||
// 类型 | Types
|
||||
export * from './types';
|
||||
|
||||
// 头部组件 | Header components
|
||||
export * from './header';
|
||||
|
||||
// 分组组件 | Section components
|
||||
export * from './sections';
|
||||
|
||||
// 控件组件 | Control components
|
||||
export * from './controls';
|
||||
@@ -1,2 +0,0 @@
|
||||
export { Inspector } from './Inspector';
|
||||
export type { InspectorProps, InspectorTarget, AssetFileInfo } from './types';
|
||||
@@ -1,235 +0,0 @@
|
||||
.game-view {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-view-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
background: var(--color-bg-elevated);
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
height: 26px;
|
||||
z-index: var(--z-index-above);
|
||||
}
|
||||
|
||||
.game-view-toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.game-view-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.game-view-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.game-view-btn:hover:not(:disabled) {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.game-view-btn.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.game-view-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.game-view-btn:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.game-view-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--color-border-default);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.game-view-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.game-view-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: var(--z-index-dropdown);
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
animation: dropdownFadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes dropdownFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.game-view-dropdown-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.game-view-dropdown-menu button:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.game-view-canvas {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
background: #000;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.game-view-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.game-view-overlay-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.game-view-overlay-content svg {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.game-view-overlay-content span {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.game-view-stats {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
z-index: var(--z-index-above);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.game-view-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-view-stat-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.game-view-stat-value {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.game-view:fullscreen {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.game-view:fullscreen .game-view-toolbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.game-view:fullscreen .game-view-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.game-view-btn {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.game-view-dropdown-menu {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -1,509 +0,0 @@
|
||||
.plugin-manager-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-index-modal);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.plugin-manager-window {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 1000px;
|
||||
height: 80%;
|
||||
max-height: 700px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugin-manager-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: var(--color-bg-overlay);
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.plugin-manager-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.plugin-manager-title h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plugin-manager-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.plugin-manager-close:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.plugin-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
background: var(--color-bg-elevated);
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plugin-toolbar-left,
|
||||
.plugin-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plugin-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--color-bg-base);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.plugin-search input {
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.plugin-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.plugin-stats .stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.plugin-stats .stat-item.enabled {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.plugin-stats .stat-item.disabled {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.plugin-view-mode {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--color-bg-base);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugin-view-mode button {
|
||||
padding: 6px 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.plugin-view-mode button:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.plugin-view-mode button.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.plugin-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.plugin-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--color-text-secondary);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.plugin-categories {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.plugin-category {
|
||||
background: var(--color-bg-overlay);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugin-category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-bg-elevated);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.plugin-category-header:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.plugin-category-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.plugin-category-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.plugin-category-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.plugin-category-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-overlay);
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.plugin-category-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.plugin-category-content.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plugin-category-content.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Plugin Card (Grid View) */
|
||||
.plugin-card {
|
||||
background: var(--color-bg-base);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plugin-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.plugin-card.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.plugin-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.plugin-card-icon {
|
||||
font-size: 24px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.plugin-card-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.plugin-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.plugin-card-version {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.plugin-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
transition: all 0.2s;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.plugin-toggle:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.plugin-toggle.enabled {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.plugin-toggle.disabled {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.plugin-card-description {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.plugin-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--color-border-default);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.plugin-card-category {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.plugin-card-installed {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Plugin List (List View) */
|
||||
.plugin-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--color-bg-base);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plugin-list-item:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.plugin-list-item.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.plugin-list-icon {
|
||||
font-size: 20px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.plugin-list-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.plugin-list-name {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.plugin-list-version {
|
||||
font-size: 11px;
|
||||
font-weight: normal;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.plugin-list-description {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.plugin-list-status {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.enabled {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status-badge.disabled {
|
||||
background: var(--color-bg-overlay);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.plugin-list-toggle {
|
||||
padding: 6px 14px;
|
||||
background: var(--color-bg-overlay);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plugin-list-toggle:hover {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.plugin-content::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.plugin-content::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.plugin-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-default);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.plugin-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Plugin Manager Tabs */
|
||||
.plugin-manager-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0 20px;
|
||||
background: var(--color-bg-overlay);
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.plugin-manager-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plugin-manager-tab:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.plugin-manager-tab.active {
|
||||
color: var(--color-accent);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.plugin-publish-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--color-accent, #0e639c);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plugin-publish-btn:hover {
|
||||
background: var(--color-accent-hover, #1177bb);
|
||||
}
|
||||
|
||||
.plugin-card-footer .plugin-publish-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.plugin-list-item .plugin-publish-btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
.plugin-market-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--color-bg-primary, #1e1e1e);
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
}
|
||||
|
||||
.plugin-market-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid var(--color-border, #333);
|
||||
background: var(--color-bg-secondary, #252526);
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.plugin-market-search {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-primary, #1e1e1e);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.plugin-market-search input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.plugin-market-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.plugin-market-filter-select {
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-primary, #1e1e1e);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.plugin-market-filter-select:hover {
|
||||
border-color: var(--color-accent, #0e639c);
|
||||
}
|
||||
|
||||
.plugin-market-refresh {
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-primary, #1e1e1e);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.plugin-market-refresh:hover {
|
||||
background: var(--color-bg-hover, #2d2d30);
|
||||
}
|
||||
|
||||
.plugin-market-publish {
|
||||
padding: 8px 16px;
|
||||
background: var(--color-accent, #0e639c);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plugin-market-publish:hover {
|
||||
background: var(--color-accent-hover, #1177bb);
|
||||
}
|
||||
|
||||
.plugin-market-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.plugin-market-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.plugin-market-card {
|
||||
background: var(--color-bg-secondary, #252526);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plugin-market-card:hover {
|
||||
border-color: var(--color-accent, #0e639c);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.plugin-market-card-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.plugin-market-card-icon {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-accent-bg, rgba(14, 99, 156, 0.1));
|
||||
border-radius: 8px;
|
||||
color: var(--color-accent, #0e639c);
|
||||
}
|
||||
|
||||
.plugin-market-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugin-market-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.plugin-market-card-title span:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plugin-market-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.plugin-market-badge.official {
|
||||
background: rgba(52, 199, 89, 0.15);
|
||||
color: #34c759;
|
||||
}
|
||||
|
||||
.plugin-market-card-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
}
|
||||
|
||||
.plugin-market-card-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.plugin-market-card-description {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugin-market-card-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.plugin-market-tag {
|
||||
padding: 4px 8px;
|
||||
background: var(--color-bg-tertiary, #333);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
}
|
||||
|
||||
.plugin-market-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-border, #333);
|
||||
}
|
||||
|
||||
.plugin-market-card-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-accent, #0e639c);
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.plugin-market-card-link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.plugin-market-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plugin-market-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plugin-market-btn.install {
|
||||
background: var(--color-accent, #0e639c);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.plugin-market-btn.install:hover {
|
||||
background: var(--color-accent-hover, #1177bb);
|
||||
}
|
||||
|
||||
.plugin-market-btn.installed {
|
||||
background: rgba(52, 199, 89, 0.15);
|
||||
color: #34c759;
|
||||
}
|
||||
|
||||
.plugin-market-btn.installed:hover {
|
||||
background: rgba(52, 199, 89, 0.25);
|
||||
}
|
||||
|
||||
.plugin-market-btn.update {
|
||||
background: rgba(255, 149, 0, 0.15);
|
||||
color: #ff9500;
|
||||
}
|
||||
|
||||
.plugin-market-btn.update:hover {
|
||||
background: rgba(255, 149, 0, 0.25);
|
||||
}
|
||||
|
||||
.plugin-market-btn.installing {
|
||||
background: var(--color-bg-tertiary, #333);
|
||||
color: var(--color-text-secondary, #858585);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.plugin-market-loading,
|
||||
.plugin-market-error,
|
||||
.plugin-market-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
}
|
||||
|
||||
.plugin-market-error {
|
||||
gap: 12px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.plugin-market-error .error-icon {
|
||||
color: #ff9500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.plugin-market-error h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.plugin-market-error .error-description {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
line-height: 1.6;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.plugin-market-error .error-details {
|
||||
background: rgba(255, 59, 48, 0.1);
|
||||
border: 1px solid rgba(255, 59, 48, 0.3);
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
margin: 16px 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.plugin-market-error .error-message {
|
||||
font-size: 12px;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
color: #ff3b30;
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.plugin-market-error .retry-button,
|
||||
.plugin-market-loading button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: var(--color-accent, #0e639c);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.plugin-market-error .retry-button:hover,
|
||||
.plugin-market-loading button:hover {
|
||||
background: var(--color-accent-hover, #1177bb);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(14, 99, 156, 0.3);
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-market-direct-source-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
background: rgba(14, 99, 156, 0.1);
|
||||
border: 1px solid rgba(14, 99, 156, 0.3);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-accent, #0e639c);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.plugin-market-direct-source-toggle:hover {
|
||||
background: rgba(14, 99, 156, 0.15);
|
||||
border-color: rgba(14, 99, 156, 0.5);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(14, 99, 156, 0.2);
|
||||
}
|
||||
|
||||
.plugin-market-direct-source-toggle input[type="checkbox"] {
|
||||
position: relative;
|
||||
width: 38px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.plugin-market-direct-source-toggle input[type="checkbox"]:checked {
|
||||
background: var(--color-accent, #0e639c);
|
||||
border-color: var(--color-accent, #0e639c);
|
||||
}
|
||||
|
||||
.plugin-market-direct-source-toggle input[type="checkbox"]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.plugin-market-direct-source-toggle input[type="checkbox"]:checked::before {
|
||||
left: 19px;
|
||||
}
|
||||
|
||||
.plugin-market-direct-source-toggle .toggle-label {
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 版本选择器 */
|
||||
.plugin-market-version-select {
|
||||
padding: 2px 6px;
|
||||
background: var(--color-bg-tertiary, #333);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 3px;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plugin-market-version-select:hover {
|
||||
border-color: var(--color-accent, #0e639c);
|
||||
background: var(--color-bg-hover, #2d2d30);
|
||||
}
|
||||
|
||||
.plugin-market-version-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent, #0e639c);
|
||||
}
|
||||
|
||||
/* 更新日志 */
|
||||
.plugin-market-version-changes {
|
||||
margin: 8px 0;
|
||||
padding: 8px;
|
||||
background: rgba(14, 99, 156, 0.1);
|
||||
border: 1px solid rgba(14, 99, 156, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.plugin-market-version-changes summary {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-accent, #0e639c);
|
||||
padding: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.plugin-market-version-changes summary:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.plugin-market-version-changes p {
|
||||
margin: 8px 0 0 0;
|
||||
padding-left: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
.plugin-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.plugin-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
gap: 8px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.plugin-toolbar-left,
|
||||
.plugin-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plugin-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.plugin-search input {
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 12px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.plugin-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.plugin-stats .stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.plugin-stats .stat-item.enabled {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.plugin-stats .stat-item.disabled {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.plugin-view-mode {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugin-view-mode button {
|
||||
padding: 4px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plugin-view-mode button:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.plugin-view-mode button.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.plugin-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.plugin-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--color-text-secondary);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.plugin-categories {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.plugin-category {
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugin-category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.plugin-category-header:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.plugin-category-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.plugin-category-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.plugin-category-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.plugin-category-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-tertiary);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.plugin-category-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.plugin-category-content.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plugin-category-content.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Plugin Card (Grid View) */
|
||||
.plugin-card {
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plugin-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.plugin-card.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.plugin-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.plugin-card-icon {
|
||||
font-size: 24px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.plugin-card-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.plugin-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.plugin-card-version {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.plugin-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
transition: all 0.2s;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.plugin-toggle:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.plugin-toggle.enabled {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.plugin-toggle.disabled {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.plugin-card-description {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.plugin-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.plugin-card-category {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.plugin-card-installed {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Plugin List (List View) */
|
||||
.plugin-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plugin-list-item:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.plugin-list-item.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.plugin-list-icon {
|
||||
font-size: 20px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.plugin-list-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.plugin-list-name {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.plugin-list-version {
|
||||
font-size: 11px;
|
||||
font-weight: normal;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.plugin-list-description {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.plugin-list-status {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.enabled {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status-badge.disabled {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.plugin-list-toggle {
|
||||
padding: 6px 12px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plugin-list-toggle:hover {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.plugin-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.plugin-content::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.plugin-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.plugin-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-secondary);
|
||||
}
|
||||
@@ -1,861 +0,0 @@
|
||||
/* 统一滚动条样式 */
|
||||
.plugin-publish-wizard ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.plugin-publish-wizard ::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-primary, #1e1e1e);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.plugin-publish-wizard ::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border, #333);
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.plugin-publish-wizard ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-secondary, #858585);
|
||||
}
|
||||
|
||||
.plugin-publish-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-index-modal);
|
||||
}
|
||||
|
||||
.plugin-publish-wizard {
|
||||
background: var(--color-bg-primary, #1e1e1e);
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.plugin-publish-wizard.inline {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugin-publish-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--color-border, #333);
|
||||
}
|
||||
|
||||
.plugin-publish-wizard.inline .plugin-publish-header {
|
||||
padding: 16px 20px;
|
||||
background: var(--color-bg-secondary, #252526);
|
||||
}
|
||||
|
||||
.plugin-publish-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
}
|
||||
|
||||
.plugin-publish-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plugin-publish-close:hover {
|
||||
background: var(--color-bg-hover, #2d2d30);
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
}
|
||||
|
||||
.plugin-publish-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.plugin-publish-wizard.inline .plugin-publish-content {
|
||||
padding: 20px;
|
||||
background: var(--color-bg-primary, #1e1e1e);
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.publish-step h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
}
|
||||
|
||||
.github-auth {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-secondary, #252526);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
border-color: var(--color-accent, #0e639c);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-link {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-accent, #0e639c);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-accent-hover, #1177bb);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-bg-secondary, #252526);
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-bg-hover, #2d2d30);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
color: var(--color-accent, #0e639c);
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 59, 48, 0.1);
|
||||
border: 1px solid rgba(255, 59, 48, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #ff3b30;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.confirm-details {
|
||||
background: var(--color-bg-secondary, #252526);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.publish-step.publishing,
|
||||
.publish-step.success,
|
||||
.publish-step.error {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.publish-step.publishing svg,
|
||||
.publish-step.success svg,
|
||||
.publish-step.error svg {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.review-message {
|
||||
color: var(--color-text-secondary, #858585);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
max-width: 400px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* OAuth Authentication Styles */
|
||||
.auth-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 16px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-tab {
|
||||
flex: 1;
|
||||
padding: 10px 16px;
|
||||
background: var(--color-bg-secondary, #252526);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.auth-tab:hover {
|
||||
background: var(--color-bg-hover, #2d2d30);
|
||||
border-color: var(--color-accent, #0e639c);
|
||||
}
|
||||
|
||||
.auth-tab.active {
|
||||
background: var(--color-accent, #0e639c);
|
||||
border-color: var(--color-accent, #0e639c);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.oauth-auth,
|
||||
.token-auth {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.oauth-instructions {
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: var(--color-bg-secondary, #252526);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, #333);
|
||||
}
|
||||
|
||||
.oauth-instructions p {
|
||||
margin: 8px 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.oauth-pending,
|
||||
.oauth-success,
|
||||
.oauth-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 32px 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.oauth-pending h4,
|
||||
.oauth-success h4,
|
||||
.oauth-error h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-code-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.user-code-display label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
}
|
||||
|
||||
.code-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--color-bg-secondary, #252526);
|
||||
border: 2px solid var(--color-accent, #0e639c);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.code-text {
|
||||
flex: 1;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
letter-spacing: 4px;
|
||||
color: var(--color-accent, #0e639c);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-copy {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-copy:hover {
|
||||
background: var(--color-bg-hover, #2d2d30);
|
||||
}
|
||||
|
||||
.error-details {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary, #252526);
|
||||
border: 1px solid #ff3b30;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.error-details pre {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #ff3b30;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 发布进度样式 */
|
||||
.publish-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: var(--color-bg-secondary, #252526);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--color-bg-hover, #2d2d30);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #007acc 0%, #4fc3f7 100%);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-message {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progress-percent {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
text-align: center;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.build-log {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-hover, #2d2d30);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-top: 16px;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.log-line svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 现有 PR 提示框 */
|
||||
.existing-pr-notice {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
background: rgba(244, 180, 0, 0.1);
|
||||
border: 1px solid rgba(244, 180, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
}
|
||||
|
||||
.existing-pr-notice svg {
|
||||
color: #f4b400;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.existing-pr-notice .notice-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.existing-pr-notice strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #f4b400;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.existing-pr-notice p {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
}
|
||||
|
||||
.existing-pr-notice .btn-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #4a9eff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.existing-pr-notice .btn-link:hover {
|
||||
background: rgba(74, 158, 255, 0.25);
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
/* 版本信息样式 */
|
||||
.version-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 12px;
|
||||
background: rgba(52, 199, 89, 0.1);
|
||||
border: 1px solid rgba(52, 199, 89, 0.3);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.version-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #34c759;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-version-suggest {
|
||||
align-self: flex-start;
|
||||
padding: 6px 12px;
|
||||
background: rgba(14, 99, 156, 0.15);
|
||||
border: 1px solid rgba(14, 99, 156, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--color-accent, #0e639c);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-version-suggest:hover {
|
||||
background: rgba(14, 99, 156, 0.25);
|
||||
border-color: var(--color-accent, #0e639c);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.version-history {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: var(--color-bg-secondary, #252526);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.version-history summary {
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
padding: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.version-history summary:hover {
|
||||
color: var(--color-accent, #0e639c);
|
||||
}
|
||||
|
||||
.version-history ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 12px 0 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.version-history li {
|
||||
padding: 6px 12px;
|
||||
background: var(--color-bg-primary, #1e1e1e);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
}
|
||||
|
||||
/* 插件源选择样式 */
|
||||
.source-type-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.source-type-btn {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--color-bg-secondary, #252526);
|
||||
border: 2px solid var(--color-border, #333);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.source-type-btn:hover {
|
||||
background: var(--color-bg-hover, #2d2d30);
|
||||
border-color: var(--color-accent, #0e639c);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.source-type-btn.active {
|
||||
background: rgba(14, 99, 156, 0.15);
|
||||
border-color: var(--color-accent, #0e639c);
|
||||
box-shadow: 0 0 0 3px rgba(14, 99, 156, 0.1);
|
||||
}
|
||||
|
||||
.source-type-btn svg {
|
||||
color: var(--color-accent, #0e639c);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.source-type-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.source-type-info strong {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
}
|
||||
|
||||
.source-type-info p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
}
|
||||
|
||||
.selected-source {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(52, 199, 89, 0.1);
|
||||
border: 1px solid rgba(52, 199, 89, 0.3);
|
||||
border-radius: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.selected-source svg {
|
||||
color: #34c759;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.source-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.source-path {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
word-break: break-all;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.source-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #34c759;
|
||||
}
|
||||
|
||||
/* ZIP 文件要求说明 */
|
||||
.zip-requirements-details {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: var(--color-bg-secondary, #252526);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.zip-requirements-details summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
padding: 8px;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.zip-requirements-details summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.zip-requirements-details summary:hover {
|
||||
color: var(--color-accent, #0e639c);
|
||||
}
|
||||
|
||||
.zip-requirements-details summary svg {
|
||||
color: #f4b400;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.zip-requirements-details[open] summary {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--color-border, #333);
|
||||
}
|
||||
|
||||
.zip-requirements-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.requirement-section h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-accent, #0e639c);
|
||||
}
|
||||
|
||||
.requirement-section p {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
}
|
||||
|
||||
.requirement-section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.requirement-section li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
}
|
||||
|
||||
.requirement-section li::before {
|
||||
content: '✓';
|
||||
color: #34c759;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.requirement-section code {
|
||||
padding: 2px 6px;
|
||||
background: var(--color-bg-primary, #1e1e1e);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
.build-script-example {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: var(--color-bg-primary, #1e1e1e);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 4px;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.recommendation-notice {
|
||||
padding: 12px 16px;
|
||||
background: rgba(14, 99, 156, 0.1);
|
||||
border: 1px solid rgba(14, 99, 156, 0.3);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-accent, #0e639c);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
.plugin-update-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-index-modal);
|
||||
}
|
||||
|
||||
.plugin-update-dialog {
|
||||
background: var(--color-bg-primary, #1e1e1e);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.update-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border, #333);
|
||||
}
|
||||
|
||||
.update-dialog-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.update-dialog-close {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary, #888);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.update-dialog-close:hover {
|
||||
background: var(--color-bg-hover, rgba(255, 255, 255, 0.1));
|
||||
color: var(--color-text-primary, #fff);
|
||||
}
|
||||
|
||||
.update-dialog-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.update-dialog-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.update-dialog-step h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-description {
|
||||
color: var(--color-text-secondary, #888);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.current-plugin-info {
|
||||
padding: 12px;
|
||||
background: rgba(14, 99, 156, 0.1);
|
||||
border: 1px solid rgba(14, 99, 156, 0.3);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.current-plugin-info h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.current-plugin-info p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary, #888);
|
||||
}
|
||||
|
||||
.selected-folder-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-secondary, #252525);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary, #888);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-secondary, #252525);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-primary, #fff);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.version-input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-input-group input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-browse,
|
||||
.btn-suggest,
|
||||
.btn-view-pr,
|
||||
.btn-close,
|
||||
.btn-back,
|
||||
.btn-primary {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-browse {
|
||||
background: var(--color-accent, #0e639c);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-browse:hover {
|
||||
background: var(--color-accent-hover, #0d5a8c);
|
||||
}
|
||||
|
||||
.btn-suggest {
|
||||
background: rgba(14, 99, 156, 0.15);
|
||||
color: var(--color-accent, #0e639c);
|
||||
border: 1px solid rgba(14, 99, 156, 0.3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-suggest:hover {
|
||||
background: rgba(14, 99, 156, 0.25);
|
||||
}
|
||||
|
||||
.update-dialog-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
background: var(--color-bg-secondary, #252525);
|
||||
color: var(--color-text-primary, #fff);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background: var(--color-bg-hover, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-accent, #0e639c);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover, #0d5a8c);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--color-bg-secondary, #252525);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-accent, #0e639c);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-message {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary, #888);
|
||||
}
|
||||
|
||||
.build-log {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: var(--color-bg-secondary, #252525);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 4px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-secondary, #888);
|
||||
}
|
||||
|
||||
.success-step,
|
||||
.error-step {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: var(--color-success, #52c41a);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: var(--color-error, #ff4d4f);
|
||||
}
|
||||
|
||||
.success-message,
|
||||
.error-message {
|
||||
margin: 16px 0;
|
||||
color: var(--color-text-secondary, #888);
|
||||
}
|
||||
|
||||
.btn-view-pr,
|
||||
.btn-close {
|
||||
background: var(--color-accent, #0e639c);
|
||||
color: white;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-view-pr:hover,
|
||||
.btn-close:hover {
|
||||
background: var(--color-accent-hover, #0d5a8c);
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
.profiler-dock-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--color-bg-elevated);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profiler-dock-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
flex-shrink: 0;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.profiler-dock-header h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.profiler-dock-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profiler-dock-pause-btn,
|
||||
.profiler-dock-details-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: var(--color-bg-inset);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.profiler-dock-pause-btn:hover,
|
||||
.profiler-dock-details-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-border-strong);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.profiler-dock-pause-btn:active,
|
||||
.profiler-dock-details-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.profiler-dock-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-inset);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-text.connected {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status-text.waiting {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.status-text.disconnected {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.profiler-dock-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-tertiary);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profiler-dock-empty p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.profiler-dock-empty .hint {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.profiler-dock-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profiler-dock-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.profiler-dock-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.profiler-dock-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-default);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.profiler-dock-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--color-bg-inset);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-default);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: rgb(99, 102, 241);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.stat-value.warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.profiler-dock-systems {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profiler-dock-systems h4 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.systems-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.system-item {
|
||||
padding: 10px 12px;
|
||||
background: var(--color-bg-inset);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-default);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.system-item:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.system-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.system-item-name {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-family-mono);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.system-item-time {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-family-mono);
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.system-item-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.system-item-bar-fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.system-item-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.system-item-percentage {
|
||||
font-weight: 600;
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.system-item-entities {
|
||||
font-size: 9px;
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
.profiler-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--color-bg-base);
|
||||
}
|
||||
|
||||
.profiler-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
background: var(--color-bg-elevated);
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.profiler-toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.profiler-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profiler-stats-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.summary-item svg {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-family: var(--font-family-mono);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.summary-value.over-budget {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.summary-value.low-fps {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.profiler-sort {
|
||||
padding: 4px 8px;
|
||||
background: var(--color-bg-inset);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.profiler-sort:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.profiler-sort:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.profiler-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.profiler-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.profiler-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.profiler-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.profiler-content::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.profiler-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-default);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.profiler-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.profiler-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--color-text-tertiary);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profiler-empty p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.profiler-empty-hint {
|
||||
font-size: 11px !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.profiler-systems {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.system-row {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.system-row:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.system-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.system-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.system-rank {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 22px;
|
||||
background: var(--color-bg-inset);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-family-mono);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.system-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.system-entities {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.system-metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric-time {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-family-mono);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.metric-percentage {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-family-mono);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-inset);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.system-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--color-bg-inset);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.system-bar-fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.system-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--font-family-mono);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.profiler-footer {
|
||||
padding: 10px 12px;
|
||||
background: var(--color-bg-elevated);
|
||||
border-top: 1px solid var(--color-border-default);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profiler-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.system-row,
|
||||
.system-bar-fill,
|
||||
.profiler-btn {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,174 +0,0 @@
|
||||
.user-profile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: #888;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.login-button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-button .spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.user-avatar-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px 2px 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: #888;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.user-avatar-button:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.user-avatar,
|
||||
.user-avatar-placeholder {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
object-fit: cover;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.user-avatar-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #888;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
background: var(--color-bg-secondary, #252526);
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: var(--z-index-dropdown);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-menu-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--color-bg-tertiary, #333);
|
||||
}
|
||||
|
||||
.user-menu-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--color-accent, #0e639c);
|
||||
}
|
||||
|
||||
.user-menu-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-menu-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-menu-login {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border, #333);
|
||||
}
|
||||
|
||||
.user-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-primary, #cccccc);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.user-menu-item:hover {
|
||||
background: var(--color-bg-hover, #2d2d30);
|
||||
}
|
||||
|
||||
.user-menu-item:last-child {
|
||||
color: #ff3b30;
|
||||
}
|
||||
|
||||
.user-menu-item:last-child:hover {
|
||||
background: rgba(255, 59, 48, 0.1);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import { ICommand } from './ICommand';
|
||||
|
||||
/**
|
||||
* 命令历史记录配置
|
||||
*/
|
||||
export interface CommandManagerConfig {
|
||||
/**
|
||||
* 最大历史记录数量
|
||||
*/
|
||||
maxHistorySize?: number;
|
||||
|
||||
/**
|
||||
* 是否自动合并相似命令
|
||||
*/
|
||||
autoMerge?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令管理器
|
||||
* 管理命令的执行、撤销、重做以及历史记录
|
||||
*/
|
||||
export class CommandManager {
|
||||
private undoStack: ICommand[] = [];
|
||||
private redoStack: ICommand[] = [];
|
||||
private readonly config: Required<CommandManagerConfig>;
|
||||
private isExecuting = false;
|
||||
|
||||
constructor(config: CommandManagerConfig = {}) {
|
||||
this.config = {
|
||||
maxHistorySize: config.maxHistorySize ?? 100,
|
||||
autoMerge: config.autoMerge ?? true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行命令
|
||||
*/
|
||||
execute(command: ICommand): void {
|
||||
if (this.isExecuting) {
|
||||
throw new Error('不能在命令执行过程中执行新命令');
|
||||
}
|
||||
|
||||
this.isExecuting = true;
|
||||
|
||||
try {
|
||||
command.execute();
|
||||
|
||||
if (this.config.autoMerge && this.undoStack.length > 0) {
|
||||
const lastCommand = this.undoStack[this.undoStack.length - 1];
|
||||
if (lastCommand && lastCommand.canMergeWith(command)) {
|
||||
const mergedCommand = lastCommand.mergeWith(command);
|
||||
this.undoStack[this.undoStack.length - 1] = mergedCommand;
|
||||
this.redoStack = [];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.undoStack.push(command);
|
||||
this.redoStack = [];
|
||||
|
||||
if (this.undoStack.length > this.config.maxHistorySize) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销上一个命令
|
||||
*/
|
||||
undo(): void {
|
||||
if (this.isExecuting) {
|
||||
throw new Error('不能在命令执行过程中撤销');
|
||||
}
|
||||
|
||||
const command = this.undoStack.pop();
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isExecuting = true;
|
||||
|
||||
try {
|
||||
command.undo();
|
||||
this.redoStack.push(command);
|
||||
} catch (error) {
|
||||
this.undoStack.push(command);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重做上一个被撤销的命令
|
||||
*/
|
||||
redo(): void {
|
||||
if (this.isExecuting) {
|
||||
throw new Error('不能在命令执行过程中重做');
|
||||
}
|
||||
|
||||
const command = this.redoStack.pop();
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isExecuting = true;
|
||||
|
||||
try {
|
||||
command.execute();
|
||||
this.undoStack.push(command);
|
||||
} catch (error) {
|
||||
this.redoStack.push(command);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以撤销
|
||||
*/
|
||||
canUndo(): boolean {
|
||||
return this.undoStack.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以重做
|
||||
*/
|
||||
canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取撤销栈的描述列表
|
||||
*/
|
||||
getUndoHistory(): string[] {
|
||||
return this.undoStack.map((cmd) => cmd.getDescription());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取重做栈的描述列表
|
||||
*/
|
||||
getRedoHistory(): string[] {
|
||||
return this.redoStack.map((cmd) => cmd.getDescription());
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有历史记录
|
||||
*/
|
||||
clear(): void {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量执行命令(作为单一操作,可以一次撤销)
|
||||
*/
|
||||
executeBatch(commands: ICommand[]): void {
|
||||
if (commands.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batchCommand = new BatchCommand(commands);
|
||||
this.execute(batchCommand);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将命令推入撤销栈但不执行
|
||||
* Push command to undo stack without executing
|
||||
*
|
||||
* 用于已经执行过的操作(如拖动变换),只需要记录到历史
|
||||
* Used for operations that have already been performed (like drag transforms),
|
||||
* only need to record to history
|
||||
*/
|
||||
pushWithoutExecute(command: ICommand): void {
|
||||
if (this.config.autoMerge && this.undoStack.length > 0) {
|
||||
const lastCommand = this.undoStack[this.undoStack.length - 1];
|
||||
if (lastCommand && lastCommand.canMergeWith(command)) {
|
||||
const mergedCommand = lastCommand.mergeWith(command);
|
||||
this.undoStack[this.undoStack.length - 1] = mergedCommand;
|
||||
this.redoStack = [];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.undoStack.push(command);
|
||||
this.redoStack = [];
|
||||
|
||||
if (this.undoStack.length > this.config.maxHistorySize) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量命令
|
||||
* 将多个命令组合为一个命令
|
||||
*/
|
||||
class BatchCommand implements ICommand {
|
||||
constructor(private readonly commands: ICommand[]) {}
|
||||
|
||||
execute(): void {
|
||||
for (const command of this.commands) {
|
||||
command.execute();
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
for (let i = this.commands.length - 1; i >= 0; i--) {
|
||||
const command = this.commands[i];
|
||||
if (command) {
|
||||
command.undo();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `批量操作 (${this.commands.length} 个命令)`;
|
||||
}
|
||||
|
||||
canMergeWith(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
mergeWith(): ICommand {
|
||||
throw new Error('批量命令不支持合并');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user