Compare commits
64 Commits
issue-120-
...
issue-151-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a801e4f50e | ||
|
|
a9f9ad9b94 | ||
|
|
3cf1dab5b9 | ||
|
|
63165bbbfc | ||
|
|
61caad2bef | ||
|
|
b826bbc4c7 | ||
|
|
2ce7dad8d8 | ||
|
|
dff400bf22 | ||
|
|
27ce902344 | ||
|
|
33ee0a04c6 | ||
|
|
d68f6922f8 | ||
|
|
f8539d7958 | ||
|
|
14dc911e0a | ||
|
|
deccb6bf84 | ||
|
|
dacbfcae95 | ||
|
|
1b69ed17b7 | ||
|
|
241acc9050 | ||
|
|
8fa921930c | ||
|
|
011e43811a | ||
|
|
9f16debd75 | ||
|
|
92c56c439b | ||
|
|
7de6a5af0f | ||
|
|
173a063781 | ||
|
|
e04ac7c909 | ||
|
|
a6e49e1d47 | ||
|
|
f0046c7dc2 | ||
|
|
2a17c47c25 | ||
|
|
8d741bf1b9 | ||
|
|
c676006632 | ||
|
|
5bcfd597b9 | ||
|
|
3cda3c2238 | ||
|
|
43bdd7e43b | ||
|
|
1ec7892338 | ||
|
|
6bcfd48a2f | ||
|
|
345ef70972 | ||
|
|
c876edca0c | ||
|
|
fcf3def284 | ||
|
|
6f1a2896dd | ||
|
|
62381f4160 | ||
|
|
171805debf | ||
|
|
619abcbfbc | ||
|
|
03909924c2 | ||
|
|
f4ea077114 | ||
|
|
956ccf9195 | ||
|
|
e880925e3f | ||
|
|
0a860920ad | ||
|
|
fb7a1b1282 | ||
|
|
59970ef7c3 | ||
|
|
a7750c2894 | ||
|
|
b69b81f63a | ||
|
|
00fc6dfd67 | ||
|
|
82451e9fd3 | ||
|
|
d0fcc0e447 | ||
|
|
285279629e | ||
|
|
cbfe09b5e9 | ||
|
|
b757c1d06c | ||
|
|
4550a6146a | ||
|
|
3224bb9696 | ||
|
|
3a5e73266e | ||
|
|
1cf5641c4c | ||
|
|
85dad41e60 | ||
|
|
bd839cf431 | ||
|
|
b20b2ae4ce | ||
|
|
cac6aedf78 |
35
.editorconfig
Normal file
@@ -0,0 +1,35 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# 所有文件的默认设置
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
# TypeScript/JavaScript 文件
|
||||
[*.{ts,tsx,js,jsx,mjs,cjs}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
# JSON 文件
|
||||
[*.json]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# YAML 文件
|
||||
[*.{yml,yaml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# Markdown 文件
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
indent_size = 2
|
||||
|
||||
# 包管理文件
|
||||
[{package.json,package-lock.json,tsconfig.json}]
|
||||
indent_size = 2
|
||||
45
.eslintrc.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020,
|
||||
"sourceType": "module",
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"semi": ["error", "always"],
|
||||
"quotes": ["error", "single", { "avoidEscape": true }],
|
||||
"indent": ["error", 4, { "SwitchCase": 1 }],
|
||||
"no-trailing-spaces": "error",
|
||||
"eol-last": ["error", "always"],
|
||||
"comma-dangle": ["error", "none"],
|
||||
"object-curly-spacing": ["error", "always"],
|
||||
"array-bracket-spacing": ["error", "never"],
|
||||
"arrow-parens": ["error", "always"],
|
||||
"no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1 }],
|
||||
"no-console": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-non-null-assertion": "off"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"node_modules/",
|
||||
"dist/",
|
||||
"bin/",
|
||||
"build/",
|
||||
"coverage/",
|
||||
"thirdparty/",
|
||||
"examples/lawn-mower-demo/",
|
||||
"extensions/",
|
||||
"*.min.js",
|
||||
"*.d.ts"
|
||||
]
|
||||
}
|
||||
44
.gitattributes
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# 自动检测文本文件并规范化换行符
|
||||
* text=auto
|
||||
|
||||
# 源代码文件强制使用 LF
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.cjs text eol=lf
|
||||
*.json text eol=lf
|
||||
*.md text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
|
||||
# 配置文件强制使用 LF
|
||||
.gitignore text eol=lf
|
||||
.gitattributes text eol=lf
|
||||
.editorconfig text eol=lf
|
||||
.prettierrc text eol=lf
|
||||
.prettierignore text eol=lf
|
||||
.eslintrc.json text eol=lf
|
||||
tsconfig.json text eol=lf
|
||||
|
||||
# Shell 脚本强制使用 LF
|
||||
*.sh text eol=lf
|
||||
|
||||
# Windows 批处理文件使用 CRLF
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
# 二进制文件不转换
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.svg binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.otf binary
|
||||
151
.github/workflows/release-editor.yml
vendored
Normal file
@@ -0,0 +1,151 @@
|
||||
name: Release Editor App
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'editor-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release version (e.g., 1.0.0)'
|
||||
required: true
|
||||
default: '1.0.0'
|
||||
|
||||
jobs:
|
||||
build-tauri:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
arch: x64
|
||||
- platform: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
arch: x64
|
||||
- platform: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
arch: arm64
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: packages/editor-app/src-tauri
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Install dependencies (Ubuntu)
|
||||
if: matrix.platform == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Update version in config files (for manual trigger)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
cd packages/editor-app
|
||||
# 临时更新版本号用于构建(不提交到仓库)
|
||||
npm version ${{ github.event.inputs.version }} --no-git-tag-version
|
||||
node scripts/sync-version.js
|
||||
|
||||
- name: Cache TypeScript build
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
packages/core/bin
|
||||
packages/editor-core/dist
|
||||
key: ${{ runner.os }}-ts-build-${{ hashFiles('packages/core/src/**', 'packages/editor-core/src/**') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-ts-build-
|
||||
|
||||
- name: Build core package
|
||||
run: npm run build:core
|
||||
|
||||
- name: Build editor-core package
|
||||
run: |
|
||||
cd packages/editor-core
|
||||
npm run build
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0.5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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
|
||||
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.'
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
includeUpdaterJson: true
|
||||
updaterJsonKeepUniversal: false
|
||||
args: ${{ matrix.platform == 'macos-latest' && format('--target {0}', matrix.target) || '' }}
|
||||
|
||||
# 构建成功后,创建 PR 更新版本号
|
||||
update-version-pr:
|
||||
needs: build-tauri
|
||||
if: github.event_name == 'workflow_dispatch' && success()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: Update version files
|
||||
run: |
|
||||
cd packages/editor-app
|
||||
npm version ${{ github.event.inputs.version }} --no-git-tag-version
|
||||
node scripts/sync-version.js
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: "chore(editor): bump version to ${{ github.event.inputs.version }}"
|
||||
branch: release/editor-v${{ github.event.inputs.version }}
|
||||
delete-branch: true
|
||||
title: "chore(editor): Release v${{ github.event.inputs.version }}"
|
||||
body: |
|
||||
## 🚀 Release v${{ github.event.inputs.version }}
|
||||
|
||||
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 }}`
|
||||
|
||||
### Release
|
||||
- 📦 [GitHub Release](https://github.com/${{ github.repository }}/releases/tag/editor-v${{ github.event.inputs.version }})
|
||||
|
||||
---
|
||||
*This PR was automatically created by the release workflow.*
|
||||
labels: |
|
||||
release
|
||||
editor
|
||||
automated pr
|
||||
9
.gitignore
vendored
@@ -69,3 +69,12 @@ docs/.vitepress/dist/
|
||||
/demo/.idea/
|
||||
/demo/.vscode/
|
||||
/demo_wxgame/
|
||||
|
||||
# Tauri 构建产物
|
||||
**/src-tauri/target/
|
||||
**/src-tauri/WixTools/
|
||||
**/src-tauri/gen/
|
||||
|
||||
# Tauri 捆绑输出
|
||||
**/src-tauri/target/release/bundle/
|
||||
**/src-tauri/target/debug/bundle/
|
||||
|
||||
49
.prettierignore
Normal file
@@ -0,0 +1,49 @@
|
||||
# 依赖和构建输出
|
||||
node_modules/
|
||||
dist/
|
||||
bin/
|
||||
build/
|
||||
coverage/
|
||||
*.min.js
|
||||
*.min.css
|
||||
|
||||
# 编译输出
|
||||
**/*.d.ts
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# 第三方库
|
||||
thirdparty/
|
||||
examples/lawn-mower-demo/
|
||||
extensions/
|
||||
|
||||
# 文档生成
|
||||
docs/.vitepress/cache/
|
||||
docs/.vitepress/dist/
|
||||
docs/api/
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.bak
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 编辑器
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# 其他
|
||||
*.backup
|
||||
CHANGELOG.md
|
||||
LICENSE
|
||||
README.md
|
||||
14
.prettierrc
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 120,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"bracketSpacing": true,
|
||||
"quoteProps": "as-needed",
|
||||
"jsxSingleQuote": false,
|
||||
"proseWrap": "preserve"
|
||||
}
|
||||
40
README.md
@@ -95,11 +95,47 @@ function gameLoop(deltaTime: number) {
|
||||
|
||||
支持主流游戏引擎和 Web 平台:
|
||||
|
||||
- **Cocos Creator** - 内置引擎集成支持,提供[专用调试插件](https://store.cocos.com/app/detail/7823)
|
||||
- **Laya 引擎** - 完整的生命周期管理
|
||||
- **Cocos Creator**
|
||||
- **Laya 引擎**
|
||||
- **原生 Web** - 浏览器环境直接运行
|
||||
- **小游戏平台** - 微信、支付宝等小游戏
|
||||
|
||||
## ECS Framework Editor
|
||||
|
||||
跨平台桌面编辑器,提供可视化开发和调试工具。
|
||||
|
||||
### 主要功能
|
||||
|
||||
- **场景管理** - 可视化场景层级和实体管理
|
||||
- **组件检视** - 实时查看和编辑实体组件
|
||||
- **性能分析** - 内置 Profiler 监控系统性能
|
||||
- **插件系统** - 可扩展的插件架构
|
||||
- **远程调试** - 连接运行中的游戏进行实时调试
|
||||
- **自动更新** - 支持热更新,自动获取最新版本
|
||||
|
||||
### 下载
|
||||
|
||||
[](https://github.com/esengine/ecs-framework/releases/latest)
|
||||
|
||||
支持 Windows、macOS (Intel & Apple Silicon)
|
||||
|
||||
### 截图
|
||||
|
||||
<img src="screenshots/main_screetshot.png" alt="ECS Framework Editor" width="800">
|
||||
|
||||
<details>
|
||||
<summary>查看更多截图</summary>
|
||||
|
||||
**性能分析器**
|
||||
<img src="screenshots/performance_profiler.png" alt="Performance Profiler" width="600">
|
||||
|
||||
**插件管理**
|
||||
<img src="screenshots/plugin_manager.png" alt="Plugin Manager" width="600">
|
||||
|
||||
**设置界面**
|
||||
<img src="screenshots/settings.png" alt="Settings" width="600">
|
||||
|
||||
</details>
|
||||
|
||||
## 示例项目
|
||||
|
||||
|
||||
2702
package-lock.json
generated
10
package.json
@@ -55,7 +55,11 @@
|
||||
"docs:api": "typedoc",
|
||||
"docs:api:watch": "typedoc --watch",
|
||||
"update:worker-demo": "npm run build:core && cd examples/worker-system-demo && npm run build && cd ../.. && npm run copy:worker-demo",
|
||||
"copy:worker-demo": "node scripts/update-worker-demo.js"
|
||||
"copy:worker-demo": "node scripts/update-worker-demo.js",
|
||||
"format": "prettier --write \"packages/**/src/**/*.{ts,tsx,js,jsx}\"",
|
||||
"format:check": "prettier --check \"packages/**/src/**/*.{ts,tsx,js,jsx}\"",
|
||||
"lint": "eslint \"packages/**/src/**/*.{ts,tsx,js,jsx}\"",
|
||||
"lint:fix": "eslint \"packages/**/src/**/*.{ts,tsx,js,jsx}\" --fix"
|
||||
},
|
||||
"author": "yhh",
|
||||
"license": "MIT",
|
||||
@@ -66,9 +70,13 @@
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^20.19.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.1",
|
||||
"@typescript-eslint/parser": "^8.46.1",
|
||||
"eslint": "^9.37.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"lerna": "^8.1.8",
|
||||
"prettier": "^3.6.2",
|
||||
"rimraf": "^5.0.0",
|
||||
"rollup": "^4.42.0",
|
||||
"rollup-plugin-dts": "^6.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/ecs-framework",
|
||||
"version": "2.2.4",
|
||||
"version": "2.2.5",
|
||||
"description": "用于Laya、Cocos Creator等JavaScript游戏引擎的高性能ECS框架",
|
||||
"main": "bin/index.js",
|
||||
"types": "bin/index.d.ts",
|
||||
|
||||
@@ -180,7 +180,7 @@ export class Core {
|
||||
this._serviceContainer.registerInstance(PoolManager, this._poolManager);
|
||||
|
||||
// 初始化场景管理器
|
||||
this._sceneManager = new SceneManager();
|
||||
this._sceneManager = new SceneManager(this._performanceMonitor);
|
||||
this._serviceContainer.registerInstance(SceneManager, this._sceneManager);
|
||||
|
||||
// 设置场景切换回调,通知调试管理器
|
||||
|
||||
@@ -70,8 +70,7 @@ export abstract class Component implements IComponent {
|
||||
* 这是一个生命周期钩子,用于组件的初始化逻辑。
|
||||
* 虽然保留此方法,但建议将复杂的初始化逻辑放在 System 中处理。
|
||||
*/
|
||||
public onAddedToEntity(): void {
|
||||
}
|
||||
public onAddedToEntity(): void {}
|
||||
|
||||
/**
|
||||
* 组件从实体移除时的回调
|
||||
@@ -82,7 +81,5 @@ export abstract class Component implements IComponent {
|
||||
* 这是一个生命周期钩子,用于组件的清理逻辑。
|
||||
* 虽然保留此方法,但建议将复杂的清理逻辑放在 System 中处理。
|
||||
*/
|
||||
public onRemovedFromEntity(): void {
|
||||
}
|
||||
|
||||
}
|
||||
public onRemovedFromEntity(): void {}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ComponentStorageManager } from './Core/ComponentStorage';
|
||||
import { QuerySystem } from './Core/QuerySystem';
|
||||
import { TypeSafeEventSystem } from './Core/EventSystem';
|
||||
import type { ReferenceTracker } from './Core/ReferenceTracker';
|
||||
import type { ServiceContainer } from '../Core/ServiceContainer';
|
||||
|
||||
/**
|
||||
* 场景接口定义
|
||||
@@ -67,6 +68,13 @@ export interface IScene {
|
||||
*/
|
||||
readonly referenceTracker: ReferenceTracker;
|
||||
|
||||
/**
|
||||
* 服务容器
|
||||
*
|
||||
* 场景级别的依赖注入容器,用于管理服务的生命周期。
|
||||
*/
|
||||
readonly services: ServiceContainer;
|
||||
|
||||
/**
|
||||
* 获取系统列表
|
||||
*/
|
||||
@@ -171,12 +179,4 @@ export interface ISceneConfig {
|
||||
* 场景名称
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* 性能监控器实例(可选)
|
||||
*
|
||||
* 如果不提供,Scene会自动从Core.services获取全局PerformanceMonitor。
|
||||
* 提供此参数可以实现场景级别的独立性能监控。
|
||||
*/
|
||||
performanceMonitor?: any;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { createLogger } from '../Utils/Logger';
|
||||
|
||||
/**
|
||||
* 游戏场景默认实现类
|
||||
*
|
||||
*
|
||||
* 实现IScene接口,提供场景的基础功能。
|
||||
* 推荐使用组合而非继承的方式来构建自定义场景。
|
||||
*/
|
||||
@@ -97,11 +97,11 @@ export class Scene implements IScene {
|
||||
private readonly logger: ReturnType<typeof createLogger>;
|
||||
|
||||
/**
|
||||
* 性能监控器
|
||||
* 性能监控器缓存
|
||||
*
|
||||
* 用于监控场景和系统的性能。可以在构造函数中注入,如果不提供则从Core获取。
|
||||
* 用于监控场景和系统的性能。从 ServiceContainer 获取。
|
||||
*/
|
||||
private readonly _performanceMonitor: PerformanceMonitor;
|
||||
private _performanceMonitor: PerformanceMonitor | null = null;
|
||||
|
||||
/**
|
||||
* 场景是否已开始运行
|
||||
@@ -182,10 +182,6 @@ export class Scene implements IScene {
|
||||
this._services = new ServiceContainer();
|
||||
this.logger = createLogger('Scene');
|
||||
|
||||
// 从配置获取 PerformanceMonitor,如果未提供则创建一个新实例
|
||||
// Scene 应该是独立的,不依赖于 Core,通过构造函数参数明确依赖关系
|
||||
this._performanceMonitor = config?.performanceMonitor || new PerformanceMonitor();
|
||||
|
||||
if (config?.name) {
|
||||
this.name = config.name;
|
||||
}
|
||||
@@ -201,6 +197,19 @@ export class Scene implements IScene {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取性能监控器
|
||||
*
|
||||
* 从 ServiceContainer 获取,如果未注册则创建默认实例(向后兼容)
|
||||
*/
|
||||
private get performanceMonitor(): PerformanceMonitor {
|
||||
if (!this._performanceMonitor) {
|
||||
this._performanceMonitor = this._services.tryResolve(PerformanceMonitor)
|
||||
?? new PerformanceMonitor();
|
||||
}
|
||||
return this._performanceMonitor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化场景
|
||||
*
|
||||
@@ -578,7 +587,7 @@ export class Scene implements IScene {
|
||||
|
||||
system.scene = this;
|
||||
|
||||
system.setPerformanceMonitor(this._performanceMonitor);
|
||||
system.setPerformanceMonitor(this.performanceMonitor);
|
||||
|
||||
const metadata = getSystemMetadata(constructor);
|
||||
if (metadata?.updateOrder !== undefined) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Time } from '../Utils/Time';
|
||||
import { createLogger } from '../Utils/Logger';
|
||||
import type { IService } from '../Core/ServiceContainer';
|
||||
import { World } from './World';
|
||||
import { PerformanceMonitor } from '../Utils/PerformanceMonitor';
|
||||
|
||||
/**
|
||||
* 单场景管理器
|
||||
@@ -73,14 +74,20 @@ export class SceneManager implements IService {
|
||||
*/
|
||||
private _onSceneChangedCallback?: () => void;
|
||||
|
||||
/**
|
||||
* 性能监控器(从 Core 注入)
|
||||
*/
|
||||
private _performanceMonitor: PerformanceMonitor | null = null;
|
||||
|
||||
/**
|
||||
* 默认场景ID
|
||||
*/
|
||||
private static readonly DEFAULT_SCENE_ID = '__main__';
|
||||
|
||||
constructor() {
|
||||
constructor(performanceMonitor?: PerformanceMonitor) {
|
||||
this._defaultWorld = new World({ name: '__default__' });
|
||||
this._defaultWorld.start();
|
||||
this._performanceMonitor = performanceMonitor || null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +118,11 @@ export class SceneManager implements IService {
|
||||
// 移除旧场景
|
||||
this._defaultWorld.removeAllScenes();
|
||||
|
||||
// 注册全局 PerformanceMonitor 到 Scene 的 ServiceContainer
|
||||
if (this._performanceMonitor) {
|
||||
scene.services.registerInstance(PerformanceMonitor, this._performanceMonitor);
|
||||
}
|
||||
|
||||
// 通过 World 创建新场景
|
||||
this._defaultWorld.createScene(SceneManager.DEFAULT_SCENE_ID, scene);
|
||||
this._defaultWorld.setSceneActive(SceneManager.DEFAULT_SCENE_ID, true);
|
||||
|
||||
@@ -275,15 +275,38 @@ export class SceneSerializer {
|
||||
|
||||
// 将实体添加到场景
|
||||
for (const entity of entities) {
|
||||
scene.addEntity(entity);
|
||||
scene.addEntity(entity, true);
|
||||
this.addChildrenRecursively(entity, scene);
|
||||
}
|
||||
|
||||
// 统一清理缓存(批量操作完成后)
|
||||
scene.querySystem.clearCache();
|
||||
scene.clearSystemEntityCaches();
|
||||
|
||||
// 反序列化场景自定义数据
|
||||
if (serializedScene.sceneData) {
|
||||
this.deserializeSceneData(serializedScene.sceneData, scene.sceneData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归添加实体的所有子实体到场景
|
||||
*
|
||||
* 修复反序列化时子实体丢失的问题:
|
||||
* EntitySerializer.deserialize会提前设置子实体的scene引用,
|
||||
* 导致Entity.addChild的条件判断(!child.scene)跳过scene.addEntity调用。
|
||||
* 因此需要在SceneSerializer中统一递归添加所有子实体。
|
||||
*
|
||||
* @param entity 父实体
|
||||
* @param scene 目标场景
|
||||
*/
|
||||
private static addChildrenRecursively(entity: Entity, scene: IScene): void {
|
||||
for (const child of entity.children) {
|
||||
scene.addEntity(child, true); // 延迟缓存清理
|
||||
this.addChildrenRecursively(child, scene); // 递归处理子实体的子实体
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化场景自定义数据
|
||||
*
|
||||
|
||||
359
packages/core/src/Plugins/DebugPlugin.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import type { Core } from '../Core';
|
||||
import type { ServiceContainer } from '../Core/ServiceContainer';
|
||||
import { IPlugin } from '../Core/Plugin';
|
||||
import { createLogger } from '../Utils/Logger';
|
||||
import type { Scene } from '../ECS/Scene';
|
||||
import type { IScene } from '../ECS/IScene';
|
||||
import type { Entity } from '../ECS/Entity';
|
||||
import type { Component } from '../ECS/Component';
|
||||
import type { EntitySystem } from '../ECS/Systems/EntitySystem';
|
||||
import { WorldManager } from '../ECS/WorldManager';
|
||||
import { Injectable, Inject } from '../Core/DI/Decorators';
|
||||
import type { IService } from '../Core/ServiceContainer';
|
||||
import type { PerformanceData } from '../Utils/PerformanceMonitor';
|
||||
|
||||
const logger = createLogger('DebugPlugin');
|
||||
|
||||
/**
|
||||
* ECS 调试插件统计信息
|
||||
*/
|
||||
export interface ECSDebugStats {
|
||||
scenes: SceneDebugInfo[];
|
||||
totalEntities: number;
|
||||
totalSystems: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景调试信息
|
||||
*/
|
||||
export interface SceneDebugInfo {
|
||||
name: string;
|
||||
entityCount: number;
|
||||
systems: SystemDebugInfo[];
|
||||
entities: EntityDebugInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统调试信息
|
||||
*/
|
||||
export interface SystemDebugInfo {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
updateOrder: number;
|
||||
entityCount: number;
|
||||
performance?: {
|
||||
avgExecutionTime: number;
|
||||
maxExecutionTime: number;
|
||||
totalCalls: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 实体调试信息
|
||||
*/
|
||||
export interface EntityDebugInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
tag: number;
|
||||
componentCount: number;
|
||||
components: ComponentDebugInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件调试信息
|
||||
*/
|
||||
export interface ComponentDebugInfo {
|
||||
type: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* ECS 调试插件
|
||||
*
|
||||
* 提供运行时调试功能:
|
||||
* - 实时查看实体和组件信息
|
||||
* - System 执行统计
|
||||
* - 性能监控
|
||||
* - 实体查询
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const core = Core.create();
|
||||
* const debugPlugin = new DebugPlugin({ autoStart: true, updateInterval: 1000 });
|
||||
* await core.pluginManager.install(debugPlugin);
|
||||
*
|
||||
* // 获取调试信息
|
||||
* const stats = debugPlugin.getStats();
|
||||
* console.log('Total entities:', stats.totalEntities);
|
||||
*
|
||||
* // 查询实体
|
||||
* const entities = debugPlugin.queryEntities({ tag: 1 });
|
||||
* ```
|
||||
*/
|
||||
@Injectable()
|
||||
export class DebugPlugin implements IPlugin, IService {
|
||||
readonly name = '@esengine/debug-plugin';
|
||||
readonly version = '1.0.0';
|
||||
|
||||
private worldManager: WorldManager | null = null;
|
||||
private updateInterval: number;
|
||||
private updateTimer: any = null;
|
||||
private autoStart: boolean;
|
||||
|
||||
/**
|
||||
* 创建调试插件实例
|
||||
*
|
||||
* @param options - 配置选项
|
||||
*/
|
||||
constructor(options?: { autoStart?: boolean; updateInterval?: number }) {
|
||||
this.autoStart = options?.autoStart ?? false;
|
||||
this.updateInterval = options?.updateInterval ?? 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装插件
|
||||
*/
|
||||
async install(core: Core, services: ServiceContainer): Promise<void> {
|
||||
this.worldManager = services.resolve(WorldManager);
|
||||
|
||||
logger.info('ECS Debug Plugin installed');
|
||||
|
||||
if (this.autoStart) {
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载插件
|
||||
*/
|
||||
async uninstall(): Promise<void> {
|
||||
this.stop();
|
||||
this.worldManager = null;
|
||||
|
||||
logger.info('ECS Debug Plugin uninstalled');
|
||||
}
|
||||
|
||||
/**
|
||||
* 实现 IService 接口
|
||||
*/
|
||||
public dispose(): void {
|
||||
this.stop();
|
||||
this.worldManager = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动调试监控
|
||||
*/
|
||||
public start(): void {
|
||||
if (this.updateTimer) {
|
||||
logger.warn('Debug monitoring already started');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Starting debug monitoring');
|
||||
|
||||
this.updateTimer = setInterval(() => {
|
||||
this.logStats();
|
||||
}, this.updateInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止调试监控
|
||||
*/
|
||||
public stop(): void {
|
||||
if (this.updateTimer) {
|
||||
clearInterval(this.updateTimer);
|
||||
this.updateTimer = null;
|
||||
logger.info('Debug monitoring stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 ECS 统计信息
|
||||
*/
|
||||
public getStats(): ECSDebugStats {
|
||||
if (!this.worldManager) {
|
||||
throw new Error('Plugin not installed');
|
||||
}
|
||||
|
||||
const scenes: SceneDebugInfo[] = [];
|
||||
let totalEntities = 0;
|
||||
let totalSystems = 0;
|
||||
|
||||
const worlds = this.worldManager.getAllWorlds();
|
||||
|
||||
for (const world of worlds) {
|
||||
for (const scene of world.getAllScenes()) {
|
||||
const sceneInfo = this.getSceneInfo(scene);
|
||||
scenes.push(sceneInfo);
|
||||
totalEntities += sceneInfo.entityCount;
|
||||
totalSystems += sceneInfo.systems.length;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scenes,
|
||||
totalEntities,
|
||||
totalSystems,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取场景调试信息
|
||||
*/
|
||||
public getSceneInfo(scene: IScene): SceneDebugInfo {
|
||||
const entities = scene.entities.buffer;
|
||||
const systems = scene.systems;
|
||||
|
||||
return {
|
||||
name: scene.name,
|
||||
entityCount: entities.length,
|
||||
systems: systems.map(sys => this.getSystemInfo(sys)),
|
||||
entities: entities.map(entity => this.getEntityInfo(entity))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统调试信息
|
||||
*/
|
||||
private getSystemInfo(system: EntitySystem): SystemDebugInfo {
|
||||
const perfStats = system.getPerformanceStats();
|
||||
|
||||
return {
|
||||
name: system.constructor.name,
|
||||
enabled: system.enabled,
|
||||
updateOrder: system.updateOrder,
|
||||
entityCount: system.entities.length,
|
||||
performance: perfStats ? {
|
||||
avgExecutionTime: perfStats.averageTime,
|
||||
maxExecutionTime: perfStats.maxTime,
|
||||
totalCalls: perfStats.executionCount
|
||||
} : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实体调试信息
|
||||
*/
|
||||
public getEntityInfo(entity: Entity): EntityDebugInfo {
|
||||
const components = entity.components;
|
||||
|
||||
return {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
enabled: entity.enabled,
|
||||
tag: entity.tag,
|
||||
componentCount: components.length,
|
||||
components: components.map(comp => this.getComponentInfo(comp))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取组件调试信息
|
||||
*/
|
||||
private getComponentInfo(component: any): ComponentDebugInfo {
|
||||
const type = component.constructor.name;
|
||||
const data: any = {};
|
||||
|
||||
for (const key of Object.keys(component)) {
|
||||
if (!key.startsWith('_')) {
|
||||
const value = component[key];
|
||||
if (typeof value !== 'function') {
|
||||
data[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询实体
|
||||
*
|
||||
* @param filter - 查询过滤器
|
||||
*/
|
||||
public queryEntities(filter: {
|
||||
sceneId?: string;
|
||||
tag?: number;
|
||||
name?: string;
|
||||
hasComponent?: string;
|
||||
}): EntityDebugInfo[] {
|
||||
if (!this.worldManager) {
|
||||
throw new Error('Plugin not installed');
|
||||
}
|
||||
|
||||
const results: EntityDebugInfo[] = [];
|
||||
const worlds = this.worldManager.getAllWorlds();
|
||||
|
||||
for (const world of worlds) {
|
||||
for (const scene of world.getAllScenes()) {
|
||||
if (filter.sceneId && scene.name !== filter.sceneId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entity of scene.entities.buffer) {
|
||||
if (filter.tag !== undefined && entity.tag !== filter.tag) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filter.name && !entity.name.includes(filter.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filter.hasComponent) {
|
||||
const hasComp = entity.components.some(
|
||||
c => c.constructor.name === filter.hasComponent
|
||||
);
|
||||
if (!hasComp) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
results.push(this.getEntityInfo(entity));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印统计信息到日志
|
||||
*/
|
||||
private logStats(): void {
|
||||
const stats = this.getStats();
|
||||
|
||||
logger.info('=== ECS Debug Stats ===');
|
||||
logger.info(`Total Entities: ${stats.totalEntities}`);
|
||||
logger.info(`Total Systems: ${stats.totalSystems}`);
|
||||
logger.info(`Scenes: ${stats.scenes.length}`);
|
||||
|
||||
for (const scene of stats.scenes) {
|
||||
logger.info(`\n[Scene: ${scene.name}]`);
|
||||
logger.info(` Entities: ${scene.entityCount}`);
|
||||
logger.info(` Systems: ${scene.systems.length}`);
|
||||
|
||||
for (const system of scene.systems) {
|
||||
const perfStr = system.performance
|
||||
? ` | Avg: ${system.performance.avgExecutionTime.toFixed(2)}ms, Max: ${system.performance.maxExecutionTime.toFixed(2)}ms`
|
||||
: '';
|
||||
logger.info(
|
||||
` - ${system.name} (${system.enabled ? 'enabled' : 'disabled'}) | Entities: ${system.entityCount}${perfStr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('========================\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出调试数据为 JSON
|
||||
*/
|
||||
public exportJSON(): string {
|
||||
const stats = this.getStats();
|
||||
return JSON.stringify(stats, null, 2);
|
||||
}
|
||||
}
|
||||
1
packages/core/src/Plugins/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './DebugPlugin';
|
||||
@@ -38,6 +38,13 @@ export class DebugManager implements IService, IUpdatable {
|
||||
private lastSendTime: number = 0;
|
||||
private sendInterval: number;
|
||||
private isRunning: boolean = false;
|
||||
private originalConsole = {
|
||||
log: console.log.bind(console),
|
||||
debug: console.debug.bind(console),
|
||||
info: console.info.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
error: console.error.bind(console)
|
||||
};
|
||||
|
||||
constructor(
|
||||
@Inject(SceneManager) sceneManager: SceneManager,
|
||||
@@ -68,6 +75,9 @@ export class DebugManager implements IService, IUpdatable {
|
||||
const debugFrameRate = this.config.debugFrameRate || 30;
|
||||
this.sendInterval = 1000 / debugFrameRate;
|
||||
|
||||
// 拦截 console 日志
|
||||
this.interceptConsole();
|
||||
|
||||
this.start();
|
||||
}
|
||||
|
||||
@@ -91,6 +101,118 @@ export class DebugManager implements IService, IUpdatable {
|
||||
this.webSocketManager.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 拦截 console 日志并转发到编辑器
|
||||
*/
|
||||
private interceptConsole(): void {
|
||||
console.log = (...args: unknown[]) => {
|
||||
this.sendLog('info', this.formatLogMessage(args));
|
||||
this.originalConsole.log(...args);
|
||||
};
|
||||
|
||||
console.debug = (...args: unknown[]) => {
|
||||
this.sendLog('debug', this.formatLogMessage(args));
|
||||
this.originalConsole.debug(...args);
|
||||
};
|
||||
|
||||
console.info = (...args: unknown[]) => {
|
||||
this.sendLog('info', this.formatLogMessage(args));
|
||||
this.originalConsole.info(...args);
|
||||
};
|
||||
|
||||
console.warn = (...args: unknown[]) => {
|
||||
this.sendLog('warn', this.formatLogMessage(args));
|
||||
this.originalConsole.warn(...args);
|
||||
};
|
||||
|
||||
console.error = (...args: unknown[]) => {
|
||||
this.sendLog('error', this.formatLogMessage(args));
|
||||
this.originalConsole.error(...args);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日志消息
|
||||
*/
|
||||
private formatLogMessage(args: unknown[]): string {
|
||||
return args.map(arg => {
|
||||
if (typeof arg === 'string') return arg;
|
||||
if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
|
||||
if (arg === null) return 'null';
|
||||
if (arg === undefined) return 'undefined';
|
||||
if (typeof arg === 'object') {
|
||||
try {
|
||||
return this.safeStringify(arg, 6);
|
||||
} catch {
|
||||
return Object.prototype.toString.call(arg);
|
||||
}
|
||||
}
|
||||
return String(arg);
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的 JSON 序列化,支持循环引用和深度限制
|
||||
*/
|
||||
private safeStringify(obj: any, maxDepth: number = 6): string {
|
||||
const seen = new WeakSet();
|
||||
|
||||
const stringify = (value: any, depth: number): any => {
|
||||
if (value === null) return null;
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'object') return value;
|
||||
|
||||
if (depth >= maxDepth) {
|
||||
return '[Max Depth Reached]';
|
||||
}
|
||||
|
||||
if (seen.has(value)) {
|
||||
return '[Circular]';
|
||||
}
|
||||
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const result = value.map(item => stringify(item, depth + 1));
|
||||
seen.delete(value);
|
||||
return result;
|
||||
}
|
||||
|
||||
const result: any = {};
|
||||
for (const key in value) {
|
||||
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
||||
result[key] = stringify(value[key], depth + 1);
|
||||
}
|
||||
}
|
||||
seen.delete(value);
|
||||
return result;
|
||||
};
|
||||
|
||||
return JSON.stringify(stringify(obj, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送日志到编辑器
|
||||
*/
|
||||
private sendLog(level: string, message: string): void {
|
||||
if (!this.webSocketManager.getConnectionStatus()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.webSocketManager.send({
|
||||
type: 'log',
|
||||
data: {
|
||||
level,
|
||||
message,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// 静默失败,避免递归日志
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
*/
|
||||
@@ -206,7 +328,8 @@ export class DebugManager implements IService, IUpdatable {
|
||||
return;
|
||||
}
|
||||
|
||||
const expandedData = this.entityCollector.expandLazyObject(entityId, componentIndex, propertyPath);
|
||||
const scene = this.sceneManager.currentScene;
|
||||
const expandedData = this.entityCollector.expandLazyObject(entityId, componentIndex, propertyPath, scene);
|
||||
|
||||
this.webSocketManager.send({
|
||||
type: 'expand_lazy_object_response',
|
||||
@@ -238,7 +361,8 @@ export class DebugManager implements IService, IUpdatable {
|
||||
return;
|
||||
}
|
||||
|
||||
const properties = this.entityCollector.getComponentProperties(entityId, componentIndex);
|
||||
const scene = this.sceneManager.currentScene;
|
||||
const properties = this.entityCollector.getComponentProperties(entityId, componentIndex, scene);
|
||||
|
||||
this.webSocketManager.send({
|
||||
type: 'get_component_properties_response',
|
||||
@@ -261,7 +385,8 @@ export class DebugManager implements IService, IUpdatable {
|
||||
try {
|
||||
const { requestId } = message;
|
||||
|
||||
const rawEntityList = this.entityCollector.getRawEntityList();
|
||||
const scene = this.sceneManager.currentScene;
|
||||
const rawEntityList = this.entityCollector.getRawEntityList(scene);
|
||||
|
||||
this.webSocketManager.send({
|
||||
type: 'get_raw_entity_list_response',
|
||||
@@ -293,7 +418,8 @@ export class DebugManager implements IService, IUpdatable {
|
||||
return;
|
||||
}
|
||||
|
||||
const entityDetails = this.entityCollector.getEntityDetails(entityId);
|
||||
const scene = this.sceneManager.currentScene;
|
||||
const entityDetails = this.entityCollector.getEntityDetails(entityId, scene);
|
||||
|
||||
this.webSocketManager.send({
|
||||
type: 'get_entity_details_response',
|
||||
@@ -825,5 +951,12 @@ export class DebugManager implements IService, IUpdatable {
|
||||
*/
|
||||
public dispose(): void {
|
||||
this.stop();
|
||||
|
||||
// 恢复原始 console 方法
|
||||
console.log = this.originalConsole.log;
|
||||
console.debug = this.originalConsole.debug;
|
||||
console.info = this.originalConsole.info;
|
||||
console.warn = this.originalConsole.warn;
|
||||
console.error = this.originalConsole.error;
|
||||
}
|
||||
}
|
||||
@@ -263,8 +263,7 @@ export class EntityDataCollector {
|
||||
componentCount: entity.components?.length || 0,
|
||||
memory: 0
|
||||
}))
|
||||
.sort((a: any, b: any) => b.componentCount - a.componentCount)
|
||||
.slice(0, 10);
|
||||
.sort((a: any, b: any) => b.componentCount - a.componentCount);
|
||||
}
|
||||
|
||||
|
||||
@@ -303,7 +302,7 @@ export class EntityDataCollector {
|
||||
});
|
||||
|
||||
if (archetype.entities) {
|
||||
archetype.entities.slice(0, 5).forEach((entity: any) => {
|
||||
archetype.entities.forEach((entity: any) => {
|
||||
topEntities.push({
|
||||
id: entity.id.toString(),
|
||||
name: entity.name || `Entity_${entity.id}`,
|
||||
@@ -352,7 +351,7 @@ export class EntityDataCollector {
|
||||
});
|
||||
|
||||
if (archetype.entities) {
|
||||
archetype.entities.slice(0, 5).forEach((entity: any) => {
|
||||
archetype.entities.forEach((entity: any) => {
|
||||
topEntities.push({
|
||||
id: entity.id.toString(),
|
||||
name: entity.name || `Entity_${entity.id}`,
|
||||
|
||||
@@ -13,6 +13,9 @@ export { PluginManager } from './Core/PluginManager';
|
||||
export { PluginState } from './Core/Plugin';
|
||||
export type { IPlugin, IPluginMetadata } from './Core/Plugin';
|
||||
|
||||
// 内置插件
|
||||
export * from './Plugins';
|
||||
|
||||
// 依赖注入
|
||||
export {
|
||||
Injectable,
|
||||
|
||||
@@ -589,25 +589,10 @@ describe('Scene - 场景管理系统测试', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('依赖注入优化', () => {
|
||||
test('应该支持注入自定义PerformanceMonitor', () => {
|
||||
const mockPerfMonitor = {
|
||||
startMeasure: jest.fn(),
|
||||
endMeasure: jest.fn(),
|
||||
recordSystemData: jest.fn(),
|
||||
recordEntityCount: jest.fn(),
|
||||
recordComponentCount: jest.fn(),
|
||||
update: jest.fn(),
|
||||
getSystemData: jest.fn(),
|
||||
getSystemStats: jest.fn(),
|
||||
resetSystem: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
dispose: jest.fn()
|
||||
};
|
||||
|
||||
describe('性能监控', () => {
|
||||
test('Scene应该自动创建PerformanceMonitor', () => {
|
||||
const customScene = new Scene({
|
||||
name: 'CustomScene',
|
||||
performanceMonitor: mockPerfMonitor as any
|
||||
name: 'CustomScene'
|
||||
});
|
||||
|
||||
class TestSystem extends EntitySystem {
|
||||
@@ -619,13 +604,14 @@ describe('Scene - 场景管理系统测试', () => {
|
||||
const system = new TestSystem();
|
||||
customScene.addEntityProcessor(system);
|
||||
|
||||
expect(mockPerfMonitor).toBeDefined();
|
||||
expect(customScene).toBeDefined();
|
||||
|
||||
customScene.end();
|
||||
});
|
||||
|
||||
test('未提供PerformanceMonitor时应该从Core获取', () => {
|
||||
const defaultScene = new Scene({ name: 'DefaultScene' });
|
||||
test('每个Scene应该有独立的PerformanceMonitor', () => {
|
||||
const scene1 = new Scene({ name: 'Scene1' });
|
||||
const scene2 = new Scene({ name: 'Scene2' });
|
||||
|
||||
class TestSystem extends EntitySystem {
|
||||
constructor() {
|
||||
@@ -633,12 +619,14 @@ describe('Scene - 场景管理系统测试', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const system = new TestSystem();
|
||||
defaultScene.addEntityProcessor(system);
|
||||
scene1.addEntityProcessor(new TestSystem());
|
||||
scene2.addEntityProcessor(new TestSystem());
|
||||
|
||||
expect(defaultScene).toBeDefined();
|
||||
expect(scene1).toBeDefined();
|
||||
expect(scene2).toBeDefined();
|
||||
|
||||
defaultScene.end();
|
||||
scene1.end();
|
||||
scene2.end();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 父子实体序列化和反序列化测试
|
||||
*
|
||||
* 测试场景序列化和反序列化时父子实体关系的正确性
|
||||
*/
|
||||
|
||||
import { Scene } from '../../../src';
|
||||
|
||||
describe('父子实体序列化测试', () => {
|
||||
let scene: Scene;
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new Scene({ name: 'TestScene' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scene.end();
|
||||
});
|
||||
|
||||
test('应该正确反序列化父子实体层次结构', () => {
|
||||
// 创建父实体
|
||||
const parent = scene.createEntity('parent');
|
||||
parent.tag = 100;
|
||||
|
||||
// 创建2个子实体
|
||||
const child1 = scene.createEntity('child1');
|
||||
child1.tag = 200;
|
||||
parent.addChild(child1);
|
||||
|
||||
const child2 = scene.createEntity('child2');
|
||||
child2.tag = 200;
|
||||
parent.addChild(child2);
|
||||
|
||||
// 创建1个顶层实体(对照组)
|
||||
const topLevel = scene.createEntity('topLevel');
|
||||
topLevel.tag = 200;
|
||||
|
||||
// 验证序列化前的状态
|
||||
expect(scene.querySystem.queryAll().entities.length).toBe(4);
|
||||
expect(scene.findEntitiesByTag(100).length).toBe(1);
|
||||
expect(scene.findEntitiesByTag(200).length).toBe(3);
|
||||
|
||||
// 序列化
|
||||
const serialized = scene.serialize({ format: 'json' });
|
||||
|
||||
// 创建新场景并反序列化
|
||||
const scene2 = new Scene({ name: 'LoadTestScene' });
|
||||
scene2.deserialize(serialized as string, {
|
||||
strategy: 'replace',
|
||||
preserveIds: true,
|
||||
});
|
||||
|
||||
// 验证所有实体都被正确恢复
|
||||
const allEntities = scene2.querySystem.queryAll().entities;
|
||||
expect(allEntities.length).toBe(4);
|
||||
expect(scene2.findEntitiesByTag(100).length).toBe(1);
|
||||
expect(scene2.findEntitiesByTag(200).length).toBe(3);
|
||||
|
||||
// 验证父子关系正确恢复
|
||||
const restoredParent = scene2.findEntity('parent');
|
||||
expect(restoredParent).not.toBeNull();
|
||||
expect(restoredParent!.children.length).toBe(2);
|
||||
|
||||
const restoredChild1 = scene2.findEntity('child1');
|
||||
const restoredChild2 = scene2.findEntity('child2');
|
||||
expect(restoredChild1).not.toBeNull();
|
||||
expect(restoredChild2).not.toBeNull();
|
||||
expect(restoredChild1!.parent).toBe(restoredParent);
|
||||
expect(restoredChild2!.parent).toBe(restoredParent);
|
||||
|
||||
scene2.end();
|
||||
});
|
||||
|
||||
test('应该正确反序列化多层级实体层次结构', () => {
|
||||
// 创建多层级实体结构:grandparent -> parent -> child
|
||||
const grandparent = scene.createEntity('grandparent');
|
||||
grandparent.tag = 1;
|
||||
|
||||
const parent = scene.createEntity('parent');
|
||||
parent.tag = 2;
|
||||
grandparent.addChild(parent);
|
||||
|
||||
const child = scene.createEntity('child');
|
||||
child.tag = 3;
|
||||
parent.addChild(child);
|
||||
|
||||
expect(scene.querySystem.queryAll().entities.length).toBe(3);
|
||||
|
||||
// 序列化
|
||||
const serialized = scene.serialize({ format: 'json' });
|
||||
|
||||
// 创建新场景并反序列化
|
||||
const scene2 = new Scene({ name: 'LoadTestScene' });
|
||||
scene2.deserialize(serialized as string, {
|
||||
strategy: 'replace',
|
||||
preserveIds: true,
|
||||
});
|
||||
|
||||
// 验证多层级结构正确恢复
|
||||
expect(scene2.querySystem.queryAll().entities.length).toBe(3);
|
||||
|
||||
const restoredGrandparent = scene2.findEntity('grandparent');
|
||||
const restoredParent = scene2.findEntity('parent');
|
||||
const restoredChild = scene2.findEntity('child');
|
||||
|
||||
expect(restoredGrandparent).not.toBeNull();
|
||||
expect(restoredParent).not.toBeNull();
|
||||
expect(restoredChild).not.toBeNull();
|
||||
|
||||
expect(restoredParent!.parent).toBe(restoredGrandparent);
|
||||
expect(restoredChild!.parent).toBe(restoredParent);
|
||||
expect(restoredGrandparent!.children.length).toBe(1);
|
||||
expect(restoredParent!.children.length).toBe(1);
|
||||
|
||||
scene2.end();
|
||||
});
|
||||
});
|
||||
352
packages/core/tests/Plugins/DebugPlugin.test.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { Core } from '../../src/Core';
|
||||
import { World } from '../../src/ECS/World';
|
||||
import { Scene } from '../../src/ECS/Scene';
|
||||
import { Component } from '../../src/ECS/Component';
|
||||
import { Matcher } from '../../src/ECS/Utils/Matcher';
|
||||
import { DebugPlugin } from '../../src/Plugins/DebugPlugin';
|
||||
import { Injectable } from '../../src/Core/DI';
|
||||
import { ECSSystem } from '../../src/ECS/Decorators';
|
||||
import { EntitySystem } from '../../src/ECS/Systems/EntitySystem';
|
||||
|
||||
class HealthComponent extends Component {
|
||||
public health: number = 100;
|
||||
public maxHealth: number = 100;
|
||||
}
|
||||
|
||||
class PositionComponent extends Component {
|
||||
public x: number = 0;
|
||||
public y: number = 0;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@ECSSystem('TestSystem', { updateOrder: 10 })
|
||||
class TestSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(PositionComponent));
|
||||
}
|
||||
|
||||
protected override process(entities: readonly import('../../src/ECS/Entity').Entity[]): void {
|
||||
// 模拟处理逻辑
|
||||
}
|
||||
}
|
||||
|
||||
describe('DebugPlugin', () => {
|
||||
let core: Core;
|
||||
let world: World;
|
||||
let scene: Scene;
|
||||
let debugPlugin: DebugPlugin;
|
||||
|
||||
beforeEach(() => {
|
||||
core = Core.create({ debug: false });
|
||||
world = Core.worldManager.createWorld('test-world', { name: 'test-world' });
|
||||
scene = world.createScene('test-scene');
|
||||
world.setSceneActive('test-scene', true);
|
||||
world.start();
|
||||
|
||||
debugPlugin = new DebugPlugin({ autoStart: false, updateInterval: 1000 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
debugPlugin.stop();
|
||||
Core.destroy();
|
||||
});
|
||||
|
||||
describe('基本功能', () => {
|
||||
it('应该能够安装插件', async () => {
|
||||
await Core.installPlugin(debugPlugin);
|
||||
expect(Core.isPluginInstalled('@esengine/debug-plugin')).toBe(true);
|
||||
});
|
||||
|
||||
it('应该能够卸载插件', async () => {
|
||||
await Core.installPlugin(debugPlugin);
|
||||
await Core.uninstallPlugin('@esengine/debug-plugin');
|
||||
expect(Core.isPluginInstalled('@esengine/debug-plugin')).toBe(false);
|
||||
});
|
||||
|
||||
it('应该能够获取插件信息', async () => {
|
||||
await Core.installPlugin(debugPlugin);
|
||||
const plugin = Core.getPlugin('@esengine/debug-plugin');
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin?.name).toBe('@esengine/debug-plugin');
|
||||
expect(plugin?.version).toBe('1.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('统计信息', () => {
|
||||
beforeEach(async () => {
|
||||
await Core.installPlugin(debugPlugin);
|
||||
});
|
||||
|
||||
it('应该能够获取 ECS 统计信息', () => {
|
||||
const entity1 = scene.createEntity('Entity1');
|
||||
entity1.addComponent(new PositionComponent());
|
||||
|
||||
const entity2 = scene.createEntity('Entity2');
|
||||
entity2.addComponent(new HealthComponent());
|
||||
|
||||
const stats = debugPlugin.getStats();
|
||||
|
||||
expect(stats).toBeDefined();
|
||||
expect(stats.totalEntities).toBe(2);
|
||||
expect(stats.scenes.length).toBe(1);
|
||||
expect(stats.scenes[0].name).toBe('test-scene');
|
||||
expect(stats.scenes[0].entityCount).toBe(2);
|
||||
});
|
||||
|
||||
it('应该能够获取场景信息', () => {
|
||||
const entity = scene.createEntity('TestEntity');
|
||||
entity.addComponent(new PositionComponent());
|
||||
entity.addComponent(new HealthComponent());
|
||||
|
||||
scene.registerSystems([TestSystem]);
|
||||
|
||||
const sceneInfo = debugPlugin.getSceneInfo(scene);
|
||||
|
||||
expect(sceneInfo.name).toBe('test-scene');
|
||||
expect(sceneInfo.entityCount).toBe(1);
|
||||
expect(sceneInfo.systems.length).toBeGreaterThan(0);
|
||||
expect(sceneInfo.entities.length).toBe(1);
|
||||
});
|
||||
|
||||
it('应该能够获取实体详细信息', () => {
|
||||
const entity = scene.createEntity('PlayerEntity');
|
||||
entity.tag = 1;
|
||||
entity.addComponent(new PositionComponent());
|
||||
entity.addComponent(new HealthComponent());
|
||||
|
||||
const entityInfo = debugPlugin.getEntityInfo(entity);
|
||||
|
||||
expect(entityInfo.name).toBe('PlayerEntity');
|
||||
expect(entityInfo.tag).toBe(1);
|
||||
expect(entityInfo.enabled).toBe(true);
|
||||
expect(entityInfo.componentCount).toBe(2);
|
||||
expect(entityInfo.components.length).toBe(2);
|
||||
|
||||
const componentTypes = entityInfo.components.map(c => c.type);
|
||||
expect(componentTypes).toContain('PositionComponent');
|
||||
expect(componentTypes).toContain('HealthComponent');
|
||||
});
|
||||
|
||||
it('应该能够获取组件数据', () => {
|
||||
const entity = scene.createEntity('TestEntity');
|
||||
const position = new PositionComponent();
|
||||
position.x = 100;
|
||||
position.y = 200;
|
||||
entity.addComponent(position);
|
||||
|
||||
const entityInfo = debugPlugin.getEntityInfo(entity);
|
||||
const positionInfo = entityInfo.components.find(c => c.type === 'PositionComponent');
|
||||
|
||||
expect(positionInfo).toBeDefined();
|
||||
expect(positionInfo?.data.x).toBe(100);
|
||||
expect(positionInfo?.data.y).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('实体查询', () => {
|
||||
beforeEach(async () => {
|
||||
await Core.installPlugin(debugPlugin);
|
||||
|
||||
const entity1 = scene.createEntity('Player');
|
||||
entity1.tag = 1;
|
||||
entity1.addComponent(new PositionComponent());
|
||||
entity1.addComponent(new HealthComponent());
|
||||
|
||||
const entity2 = scene.createEntity('Enemy');
|
||||
entity2.tag = 2;
|
||||
entity2.addComponent(new PositionComponent());
|
||||
|
||||
const entity3 = scene.createEntity('Item');
|
||||
entity3.tag = 3;
|
||||
entity3.addComponent(new HealthComponent());
|
||||
});
|
||||
|
||||
it('应该能够按 tag 查询实体', () => {
|
||||
const results = debugPlugin.queryEntities({ tag: 1 });
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].name).toBe('Player');
|
||||
expect(results[0].tag).toBe(1);
|
||||
});
|
||||
|
||||
it('应该能够按名称查询实体', () => {
|
||||
const results = debugPlugin.queryEntities({ name: 'Player' });
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].name).toBe('Player');
|
||||
});
|
||||
|
||||
it('应该能够按组件查询实体', () => {
|
||||
const results = debugPlugin.queryEntities({ hasComponent: 'PositionComponent' });
|
||||
|
||||
expect(results.length).toBe(2);
|
||||
expect(results.map(r => r.name)).toContain('Player');
|
||||
expect(results.map(r => r.name)).toContain('Enemy');
|
||||
});
|
||||
|
||||
it('应该能够组合多个过滤条件', () => {
|
||||
const results = debugPlugin.queryEntities({
|
||||
tag: 1,
|
||||
hasComponent: 'HealthComponent'
|
||||
});
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].name).toBe('Player');
|
||||
});
|
||||
|
||||
it('应该在没有匹配时返回空数组', () => {
|
||||
const results = debugPlugin.queryEntities({ tag: 999 });
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('监控功能', () => {
|
||||
beforeEach(async () => {
|
||||
await Core.installPlugin(debugPlugin);
|
||||
});
|
||||
|
||||
it('应该能够启动监控', () => {
|
||||
debugPlugin.start();
|
||||
expect(debugPlugin['updateTimer']).not.toBeNull();
|
||||
});
|
||||
|
||||
it('应该能够停止监控', () => {
|
||||
debugPlugin.start();
|
||||
debugPlugin.stop();
|
||||
expect(debugPlugin['updateTimer']).toBeNull();
|
||||
});
|
||||
|
||||
it('应该防止重复启动', () => {
|
||||
debugPlugin.start();
|
||||
const timer1 = debugPlugin['updateTimer'];
|
||||
|
||||
debugPlugin.start();
|
||||
const timer2 = debugPlugin['updateTimer'];
|
||||
|
||||
expect(timer1).toBe(timer2);
|
||||
|
||||
debugPlugin.stop();
|
||||
});
|
||||
|
||||
it('应该支持自动启动', async () => {
|
||||
await Core.uninstallPlugin('@esengine/debug-plugin');
|
||||
|
||||
const autoPlugin = new DebugPlugin({ autoStart: true, updateInterval: 100 });
|
||||
await Core.installPlugin(autoPlugin);
|
||||
|
||||
expect(autoPlugin['updateTimer']).not.toBeNull();
|
||||
|
||||
autoPlugin.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('数据导出', () => {
|
||||
beforeEach(async () => {
|
||||
await Core.installPlugin(debugPlugin);
|
||||
});
|
||||
|
||||
it('应该能够导出 JSON 格式数据', () => {
|
||||
const entity = scene.createEntity('TestEntity');
|
||||
entity.addComponent(new PositionComponent());
|
||||
|
||||
const json = debugPlugin.exportJSON();
|
||||
|
||||
expect(json).toBeDefined();
|
||||
expect(typeof json).toBe('string');
|
||||
|
||||
const data = JSON.parse(json);
|
||||
expect(data.totalEntities).toBe(1);
|
||||
expect(data.scenes).toBeDefined();
|
||||
expect(data.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it('导出的 JSON 应该包含完整的实体信息', () => {
|
||||
const entity = scene.createEntity('ComplexEntity');
|
||||
const position = new PositionComponent();
|
||||
position.x = 50;
|
||||
position.y = 75;
|
||||
entity.addComponent(position);
|
||||
|
||||
const json = debugPlugin.exportJSON();
|
||||
const data = JSON.parse(json);
|
||||
|
||||
const entityData = data.scenes[0].entities[0];
|
||||
expect(entityData.name).toBe('ComplexEntity');
|
||||
expect(entityData.components[0].data.x).toBe(50);
|
||||
expect(entityData.components[0].data.y).toBe(75);
|
||||
});
|
||||
});
|
||||
|
||||
describe('性能监控', () => {
|
||||
beforeEach(async () => {
|
||||
await Core.installPlugin(debugPlugin);
|
||||
scene.registerSystems([TestSystem]);
|
||||
});
|
||||
|
||||
it('应该能够获取 System 性能数据', () => {
|
||||
scene.createEntity('E1').addComponent(new PositionComponent());
|
||||
scene.createEntity('E2').addComponent(new PositionComponent());
|
||||
|
||||
scene.update();
|
||||
scene.update();
|
||||
scene.update();
|
||||
|
||||
const sceneInfo = debugPlugin.getSceneInfo(scene);
|
||||
const systemInfo = sceneInfo.systems.find(s => s.name === 'TestSystem');
|
||||
|
||||
expect(systemInfo).toBeDefined();
|
||||
if (systemInfo?.performance) {
|
||||
expect(systemInfo.performance.totalCalls).toBeGreaterThan(0);
|
||||
expect(systemInfo.performance.avgExecutionTime).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('应该记录 System 的实体数量', () => {
|
||||
scene.createEntity('E1').addComponent(new PositionComponent());
|
||||
scene.createEntity('E2').addComponent(new PositionComponent());
|
||||
scene.createEntity('E3').addComponent(new HealthComponent());
|
||||
|
||||
const sceneInfo = debugPlugin.getSceneInfo(scene);
|
||||
const systemInfo = sceneInfo.systems.find(s => s.name === 'TestSystem');
|
||||
|
||||
expect(systemInfo).toBeDefined();
|
||||
expect(systemInfo?.entityCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('错误处理', () => {
|
||||
it('应该在未安装时抛出错误', () => {
|
||||
expect(() => {
|
||||
debugPlugin.getStats();
|
||||
}).toThrow('Plugin not installed');
|
||||
});
|
||||
|
||||
it('应该在未安装时查询实体抛出错误', () => {
|
||||
expect(() => {
|
||||
debugPlugin.queryEntities({ tag: 1 });
|
||||
}).toThrow('Plugin not installed');
|
||||
});
|
||||
|
||||
it('应该处理空场景', async () => {
|
||||
await Core.installPlugin(debugPlugin);
|
||||
|
||||
const stats = debugPlugin.getStats();
|
||||
|
||||
expect(stats.totalEntities).toBe(0);
|
||||
expect(stats.totalSystems).toBe(0);
|
||||
});
|
||||
|
||||
it('应该处理没有 World 的情况', async () => {
|
||||
Core.destroy();
|
||||
Core.create({ debug: false });
|
||||
const tempPlugin = new DebugPlugin();
|
||||
|
||||
await Core.installPlugin(tempPlugin);
|
||||
|
||||
const stats = tempPlugin.getStats();
|
||||
|
||||
expect(stats.totalEntities).toBe(0);
|
||||
expect(stats.scenes.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
12
packages/editor-app/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ECS Framework Editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
packages/editor-app/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@esengine/editor-app",
|
||||
"version": "1.0.3",
|
||||
"description": "ECS Framework Editor Application - Cross-platform desktop editor",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"version": "node scripts/sync-version.js && git add src-tauri/tauri.conf.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/ecs-framework": "file:../core",
|
||||
"@esengine/editor-core": "file:../editor-core",
|
||||
"@tauri-apps/api": "^2.2.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"json5": "^2.2.3",
|
||||
"lucide-react": "^0.545.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.2.0",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"sharp": "^0.34.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
33
packages/editor-app/scripts/sync-version.js
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 同步 package.json 和 tauri.conf.json 的版本号
|
||||
* 在 npm version 命令执行后自动运行
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// 读取 package.json
|
||||
const packageJsonPath = join(__dirname, '../package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
||||
const newVersion = packageJson.version;
|
||||
|
||||
// 读取 tauri.conf.json
|
||||
const tauriConfigPath = join(__dirname, '../src-tauri/tauri.conf.json');
|
||||
const tauriConfig = JSON.parse(readFileSync(tauriConfigPath, 'utf8'));
|
||||
|
||||
// 更新 tauri.conf.json 的版本号
|
||||
const oldVersion = tauriConfig.version;
|
||||
tauriConfig.version = newVersion;
|
||||
|
||||
// 写回文件(保持格式)
|
||||
writeFileSync(tauriConfigPath, JSON.stringify(tauriConfig, null, 2) + '\n', 'utf8');
|
||||
|
||||
console.log(`✓ Version synced: ${oldVersion} → ${newVersion}`);
|
||||
console.log(` - package.json: ${newVersion}`);
|
||||
console.log(` - tauri.conf.json: ${newVersion}`);
|
||||
5787
packages/editor-app/src-tauri/Cargo.lock
generated
Normal file
36
packages/editor-app/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "ecs-editor"
|
||||
version = "1.0.0"
|
||||
description = "ECS Framework Editor - Cross-platform desktop editor"
|
||||
authors = ["yhh"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "ecs_editor_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.0", features = ["protocol-asset"] }
|
||||
tauri-plugin-shell = "2.0"
|
||||
tauri-plugin-dialog = "2.0"
|
||||
tauri-plugin-updater = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
glob = "0.3"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = "0.21"
|
||||
futures-util = "0.3"
|
||||
chrono = "0.4"
|
||||
|
||||
[profile.dev]
|
||||
incremental = true
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
panic = "abort"
|
||||
strip = true
|
||||
3
packages/editor-app/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
BIN
packages/editor-app/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
packages/editor-app/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
packages/editor-app/src-tauri/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
packages/editor-app/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 915 B |
BIN
packages/editor-app/src-tauri/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
packages/editor-app/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
packages/editor-app/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
packages/editor-app/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
packages/editor-app/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
packages/editor-app/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
packages/editor-app/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 888 B |
BIN
packages/editor-app/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
packages/editor-app/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
packages/editor-app/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
packages/editor-app/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
packages/editor-app/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
BIN
packages/editor-app/src-tauri/icons/icon.icns
Normal file
BIN
packages/editor-app/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
packages/editor-app/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
1
packages/editor-app/src-tauri/icons/icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill="none" viewBox="0 0 512 512"><rect width="512" height="512" fill="#1E1E1E"/><defs><radialGradient id="glow" cx="50%" cy="50%" r="50%"><stop offset="0%" style="stop-color:#569cd6;stop-opacity:.15"/><stop offset="100%" style="stop-color:#569cd6;stop-opacity:0"/></radialGradient><linearGradient id="cubeGrad1" x1="0%" x2="100%" y1="0%" y2="100%"><stop offset="0%" style="stop-color:#569cd6"/><stop offset="100%" style="stop-color:#4a8bc2"/></linearGradient><linearGradient id="cubeGrad2" x1="0%" x2="0%" y1="0%" y2="100%"><stop offset="0%" style="stop-color:#4ec9b0"/><stop offset="100%" style="stop-color:#3da592"/></linearGradient><filter id="shadow"><feGaussianBlur in="SourceAlpha" stdDeviation="4"/><feOffset dx="2" dy="2" result="offsetblur"/><feComponentTransfer><feFuncA slope=".3" type="linear"/></feComponentTransfer><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><circle cx="256" cy="256" r="200" fill="url(#glow)"/><g filter="url(#shadow)"><path fill="none" stroke="url(#cubeGrad1)" stroke-width="6" d="M 180 140 L 332 140 L 332 292 L 180 292 Z" opacity=".4"/><path fill="rgba(86, 156, 214, 0.08)" stroke="url(#cubeGrad1)" stroke-linecap="round" stroke-linejoin="round" stroke-width="8" d="M 140 180 L 292 180 L 292 332 L 140 332 Z"/><line x1="140" x2="180" y1="180" y2="140" stroke="url(#cubeGrad1)" stroke-linecap="round" stroke-width="6" opacity=".6"/><line x1="292" x2="332" y1="180" y2="140" stroke="url(#cubeGrad1)" stroke-linecap="round" stroke-width="6" opacity=".6"/><line x1="292" x2="332" y1="332" y2="292" stroke="url(#cubeGrad1)" stroke-linecap="round" stroke-width="6" opacity=".6"/><line x1="140" x2="180" y1="332" y2="292" stroke="url(#cubeGrad1)" stroke-linecap="round" stroke-width="6" opacity=".6"/><circle cx="216" cy="216" r="14" fill="#CE9178" opacity=".95"><animate attributeName="opacity" dur="2s" repeatCount="indefinite" values="0.95;1;0.95"/></circle><circle cx="256" cy="256" r="14" fill="#4EC9B0" opacity=".95"><animate attributeName="opacity" begin="0.3s" dur="2s" repeatCount="indefinite" values="0.95;1;0.95"/></circle><circle cx="296" cy="296" r="14" fill="#569CD6" opacity=".95"><animate attributeName="opacity" begin="0.6s" dur="2s" repeatCount="indefinite" values="0.95;1;0.95"/></circle><line x1="216" x2="256" y1="216" y2="256" stroke="#4EC9B0" stroke-linecap="round" stroke-width="2" opacity=".5"/><line x1="256" x2="296" y1="256" y2="296" stroke="#569CD6" stroke-linecap="round" stroke-width="2" opacity=".5"/></g><g stroke="#4EC9B0" stroke-linecap="round" stroke-width="3" opacity=".3"><line x1="130" x2="130" y1="170" y2="190"/><line x1="130" x2="150" y1="170" y2="170"/><line x1="302" x2="302" y1="342" y2="322"/><line x1="302" x2="282" y1="342" y2="342"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 512 B |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 847 B |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
packages/editor-app/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
76
packages/editor-app/src-tauri/src/commands.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use crate::profiler_ws::ProfilerServer;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ProjectInfo {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct EditorConfig {
|
||||
pub theme: String,
|
||||
pub auto_save: bool,
|
||||
pub recent_projects: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for EditorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: "dark".to_string(),
|
||||
auto_save: true,
|
||||
recent_projects: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProfilerState {
|
||||
pub server: Arc<Mutex<Option<Arc<ProfilerServer>>>>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_profiler_server(
|
||||
port: u16,
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<String, String> {
|
||||
let mut server_lock = state.server.lock().await;
|
||||
|
||||
if server_lock.is_some() {
|
||||
return Err("Profiler server is already running".to_string());
|
||||
}
|
||||
|
||||
let server = Arc::new(ProfilerServer::new(port));
|
||||
|
||||
match server.start().await {
|
||||
Ok(_) => {
|
||||
*server_lock = Some(server);
|
||||
Ok(format!("Profiler server started on port {}", port))
|
||||
}
|
||||
Err(e) => Err(format!("Failed to start profiler server: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_profiler_server(
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<String, String> {
|
||||
let mut server_lock = state.server.lock().await;
|
||||
|
||||
if server_lock.is_none() {
|
||||
return Err("Profiler server is not running".to_string());
|
||||
}
|
||||
|
||||
*server_lock = None;
|
||||
Ok("Profiler server stopped".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_profiler_status(
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<bool, String> {
|
||||
let server_lock = state.server.lock().await;
|
||||
Ok(server_lock.is_some())
|
||||
}
|
||||
9
packages/editor-app/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
// ECS Editor Library
|
||||
|
||||
pub mod commands;
|
||||
pub mod project;
|
||||
pub mod profiler_ws;
|
||||
|
||||
pub use commands::*;
|
||||
pub use project::*;
|
||||
pub use profiler_ws::*;
|
||||
371
packages/editor-app/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,371 @@
|
||||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use tauri::Manager;
|
||||
use tauri::AppHandle;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::collections::HashMap;
|
||||
use ecs_editor_lib::profiler_ws::ProfilerServer;
|
||||
|
||||
// IPC Commands
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! Welcome to ECS Framework Editor.", name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_project(path: String) -> Result<String, String> {
|
||||
// 项目打开逻辑
|
||||
Ok(format!("Project opened: {}", path))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_project(path: String, data: String) -> Result<(), String> {
|
||||
// 项目保存逻辑
|
||||
std::fs::write(&path, data)
|
||||
.map_err(|e| format!("Failed to save project: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_binary(data: Vec<u8>, output_path: String) -> Result<(), String> {
|
||||
std::fs::write(&output_path, data)
|
||||
.map_err(|e| format!("Failed to export binary: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_directory(path: String) -> Result<(), String> {
|
||||
std::fs::create_dir_all(&path)
|
||||
.map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn write_file_content(path: String, content: String) -> Result<(), String> {
|
||||
std::fs::write(&path, content)
|
||||
.map_err(|e| format!("Failed to write file: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn path_exists(path: String) -> Result<bool, String> {
|
||||
use std::path::Path;
|
||||
Ok(Path::new(&path).exists())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_project_dialog(app: AppHandle) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let folder = app.dialog()
|
||||
.file()
|
||||
.set_title("Select Project Directory")
|
||||
.blocking_pick_folder();
|
||||
|
||||
Ok(folder.map(|path| path.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_scene_dialog(app: AppHandle, default_name: Option<String>) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let mut dialog = app.dialog()
|
||||
.file()
|
||||
.set_title("Save ECS Scene")
|
||||
.add_filter("ECS Scene Files", &["ecs"]);
|
||||
|
||||
if let Some(name) = default_name {
|
||||
dialog = dialog.set_file_name(&name);
|
||||
}
|
||||
|
||||
let file = dialog.blocking_save_file();
|
||||
|
||||
Ok(file.map(|path| path.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_scene_dialog(app: AppHandle) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let file = app.dialog()
|
||||
.file()
|
||||
.set_title("Open ECS Scene")
|
||||
.add_filter("ECS Scene Files", &["ecs"])
|
||||
.blocking_pick_file();
|
||||
|
||||
Ok(file.map(|path| path.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn scan_directory(path: String, pattern: String) -> Result<Vec<String>, String> {
|
||||
use glob::glob;
|
||||
use std::path::Path;
|
||||
|
||||
let base_path = Path::new(&path);
|
||||
if !base_path.exists() {
|
||||
return Err(format!("Directory does not exist: {}", path));
|
||||
}
|
||||
|
||||
let separator = if path.contains('\\') { '\\' } else { '/' };
|
||||
let glob_pattern = format!("{}{}{}", path.trim_end_matches(&['/', '\\'][..]), separator, pattern);
|
||||
let normalized_pattern = if cfg!(windows) {
|
||||
glob_pattern.replace('/', "\\")
|
||||
} else {
|
||||
glob_pattern.replace('\\', "/")
|
||||
};
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
match glob(&normalized_pattern) {
|
||||
Ok(entries) => {
|
||||
for entry in entries {
|
||||
match entry {
|
||||
Ok(path) => {
|
||||
if path.is_file() {
|
||||
files.push(path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Error reading entry: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Failed to scan directory: {}", e)),
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_file_content(path: String) -> Result<String, String> {
|
||||
std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read file {}: {}", path, e))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct DirectoryEntry {
|
||||
name: String,
|
||||
path: String,
|
||||
is_dir: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_directory(path: String) -> Result<Vec<DirectoryEntry>, String> {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
let dir_path = Path::new(&path);
|
||||
if !dir_path.exists() {
|
||||
return Err(format!("Directory does not exist: {}", path));
|
||||
}
|
||||
|
||||
if !dir_path.is_dir() {
|
||||
return Err(format!("Path is not a directory: {}", path));
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
match fs::read_dir(dir_path) {
|
||||
Ok(read_dir) => {
|
||||
for entry in read_dir {
|
||||
match entry {
|
||||
Ok(entry) => {
|
||||
let entry_path = entry.path();
|
||||
if let Some(name) = entry_path.file_name() {
|
||||
entries.push(DirectoryEntry {
|
||||
name: name.to_string_lossy().to_string(),
|
||||
path: entry_path.to_string_lossy().to_string(),
|
||||
is_dir: entry_path.is_dir(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Error reading directory entry: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Failed to read directory: {}", e)),
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
match (a.is_dir, b.is_dir) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_project_base_path(
|
||||
path: String,
|
||||
state: tauri::State<Arc<Mutex<HashMap<String, String>>>>
|
||||
) -> Result<(), String> {
|
||||
let mut paths = state.lock().map_err(|e| format!("Failed to lock state: {}", e))?;
|
||||
paths.insert("current".to_string(), path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn toggle_devtools(app: AppHandle) -> Result<(), String> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_devtools_open() {
|
||||
window.close_devtools();
|
||||
} else {
|
||||
window.open_devtools();
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Window not found".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
Err("DevTools are only available in debug mode".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Profiler State
|
||||
pub struct ProfilerState {
|
||||
pub server: Arc<tokio::sync::Mutex<Option<Arc<ProfilerServer>>>>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_profiler_server(
|
||||
port: u16,
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<String, String> {
|
||||
let mut server_lock = state.server.lock().await;
|
||||
|
||||
if server_lock.is_some() {
|
||||
return Err("Profiler server is already running".to_string());
|
||||
}
|
||||
|
||||
let server = Arc::new(ProfilerServer::new(port));
|
||||
|
||||
match server.start().await {
|
||||
Ok(_) => {
|
||||
*server_lock = Some(server);
|
||||
Ok(format!("Profiler server started on port {}", port))
|
||||
}
|
||||
Err(e) => Err(format!("Failed to start profiler server: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stop_profiler_server(
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<String, String> {
|
||||
let mut server_lock = state.server.lock().await;
|
||||
|
||||
if server_lock.is_none() {
|
||||
return Err("Profiler server is not running".to_string());
|
||||
}
|
||||
|
||||
// 调用 stop 方法正确关闭服务器
|
||||
if let Some(server) = server_lock.as_ref() {
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
*server_lock = None;
|
||||
Ok("Profiler server stopped".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_profiler_status(
|
||||
state: tauri::State<'_, ProfilerState>,
|
||||
) -> Result<bool, String> {
|
||||
let server_lock = state.server.lock().await;
|
||||
Ok(server_lock.is_some())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let project_paths: Arc<Mutex<HashMap<String, String>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
let project_paths_clone = Arc::clone(&project_paths);
|
||||
|
||||
let profiler_state = ProfilerState {
|
||||
server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.register_uri_scheme_protocol("project", move |_app, request| {
|
||||
let project_paths = Arc::clone(&project_paths_clone);
|
||||
|
||||
let uri = request.uri();
|
||||
let path = uri.path();
|
||||
|
||||
let file_path = {
|
||||
let paths = project_paths.lock().unwrap();
|
||||
if let Some(base_path) = paths.get("current") {
|
||||
format!("{}{}", base_path, path)
|
||||
} else {
|
||||
return tauri::http::Response::builder()
|
||||
.status(404)
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
match std::fs::read(&file_path) {
|
||||
Ok(content) => {
|
||||
let mime_type = if file_path.ends_with(".ts") || file_path.ends_with(".tsx") {
|
||||
"application/javascript"
|
||||
} else if file_path.ends_with(".js") {
|
||||
"application/javascript"
|
||||
} else if file_path.ends_with(".json") {
|
||||
"application/json"
|
||||
} else {
|
||||
"text/plain"
|
||||
};
|
||||
|
||||
tauri::http::Response::builder()
|
||||
.status(200)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(content)
|
||||
.unwrap()
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to read file {}: {}", file_path, e);
|
||||
tauri::http::Response::builder()
|
||||
.status(404)
|
||||
.body(Vec::new())
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
})
|
||||
.setup(move |app| {
|
||||
app.manage(project_paths);
|
||||
app.manage(profiler_state);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
open_project,
|
||||
save_project,
|
||||
export_binary,
|
||||
create_directory,
|
||||
write_file_content,
|
||||
path_exists,
|
||||
open_project_dialog,
|
||||
save_scene_dialog,
|
||||
open_scene_dialog,
|
||||
scan_directory,
|
||||
read_file_content,
|
||||
list_directory,
|
||||
set_project_base_path,
|
||||
toggle_devtools,
|
||||
start_profiler_server,
|
||||
stop_profiler_server,
|
||||
get_profiler_status
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
188
packages/editor-app/src-tauri/src/profiler_ws.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{broadcast, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_tungstenite::{accept_async, tungstenite::Message};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
|
||||
pub struct ProfilerServer {
|
||||
tx: broadcast::Sender<String>,
|
||||
port: u16,
|
||||
shutdown_tx: Arc<Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
|
||||
task_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||
}
|
||||
|
||||
impl ProfilerServer {
|
||||
pub fn new(port: u16) -> Self {
|
||||
let (tx, _) = broadcast::channel(100);
|
||||
Self {
|
||||
tx,
|
||||
port,
|
||||
shutdown_tx: Arc::new(Mutex::new(None)),
|
||||
task_handle: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let addr = format!("127.0.0.1:{}", self.port);
|
||||
let listener = TcpListener::bind(&addr).await?;
|
||||
println!("[ProfilerServer] Listening on: {}", addr);
|
||||
|
||||
let tx = self.tx.clone();
|
||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
// 存储 shutdown sender
|
||||
*self.shutdown_tx.lock().await = Some(shutdown_tx);
|
||||
|
||||
// 启动服务器任务
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
// 监听新连接
|
||||
result = listener.accept() => {
|
||||
match result {
|
||||
Ok((stream, peer_addr)) => {
|
||||
println!("[ProfilerServer] New connection from: {}", peer_addr);
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(handle_connection(stream, peer_addr, tx));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[ProfilerServer] Failed to accept connection: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 监听关闭信号
|
||||
_ = &mut shutdown_rx => {
|
||||
println!("[ProfilerServer] Received shutdown signal");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("[ProfilerServer] Server task ending");
|
||||
});
|
||||
|
||||
// 存储任务句柄
|
||||
*self.task_handle.lock().await = Some(task);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop(&self) {
|
||||
println!("[ProfilerServer] Stopping server...");
|
||||
|
||||
// 发送关闭信号
|
||||
if let Some(shutdown_tx) = self.shutdown_tx.lock().await.take() {
|
||||
let _ = shutdown_tx.send(());
|
||||
}
|
||||
|
||||
// 等待任务完成
|
||||
if let Some(handle) = self.task_handle.lock().await.take() {
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
println!("[ProfilerServer] Server stopped");
|
||||
}
|
||||
|
||||
pub fn broadcast(&self, message: String) {
|
||||
let _ = self.tx.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
stream: TcpStream,
|
||||
peer_addr: SocketAddr,
|
||||
tx: broadcast::Sender<String>,
|
||||
) {
|
||||
let ws_stream = match accept_async(stream).await {
|
||||
Ok(ws) => ws,
|
||||
Err(e) => {
|
||||
eprintln!("[ProfilerServer] WebSocket error: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (mut ws_sender, mut ws_receiver) = ws_stream.split();
|
||||
let mut rx = tx.subscribe();
|
||||
|
||||
println!("[ProfilerServer] Client {} connected", peer_addr);
|
||||
|
||||
// Send initial connection confirmation
|
||||
let _ = ws_sender
|
||||
.send(Message::Text(
|
||||
serde_json::json!({
|
||||
"type": "connected",
|
||||
"message": "Connected to ECS Editor Profiler"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await;
|
||||
|
||||
// Spawn task to forward broadcast messages to this client
|
||||
let forward_task = tokio::spawn(async move {
|
||||
while let Ok(msg) = rx.recv().await {
|
||||
if ws_sender.send(Message::Text(msg)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle incoming messages from client
|
||||
while let Some(msg) = ws_receiver.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
// Parse incoming messages
|
||||
if let Ok(mut json_value) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
let msg_type = json_value.get("type").and_then(|t| t.as_str());
|
||||
|
||||
if msg_type == Some("debug_data") {
|
||||
// Broadcast debug data from game client to all clients (including frontend)
|
||||
tx.send(text).ok();
|
||||
} else if msg_type == Some("ping") {
|
||||
// Respond to ping
|
||||
let _ = tx.send(
|
||||
serde_json::json!({
|
||||
"type": "pong",
|
||||
"timestamp": chrono::Utc::now().timestamp_millis()
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
} else if msg_type == Some("log") {
|
||||
// Inject clientId into log messages
|
||||
if let Some(data) = json_value.get_mut("data").and_then(|d| d.as_object_mut()) {
|
||||
data.insert("clientId".to_string(), serde_json::Value::String(peer_addr.to_string()));
|
||||
}
|
||||
tx.send(json_value.to_string()).ok();
|
||||
} else {
|
||||
// Forward all other messages (like get_raw_entity_list, get_entity_details, etc.)
|
||||
// to all connected clients (this enables frontend -> game client communication)
|
||||
tx.send(text).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
println!("[ProfilerServer] Client {} disconnected", peer_addr);
|
||||
break;
|
||||
}
|
||||
Ok(Message::Ping(data)) => {
|
||||
// Respond to WebSocket ping
|
||||
tx.send(
|
||||
serde_json::json!({
|
||||
"type": "pong",
|
||||
"data": String::from_utf8_lossy(&data)
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[ProfilerServer] Error: {}", e);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
forward_task.abort();
|
||||
println!("[ProfilerServer] Connection handler ended for {}", peer_addr);
|
||||
}
|
||||
42
packages/editor-app/src-tauri/src/project.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub scenes: Vec<String>,
|
||||
pub assets: Vec<String>,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn new(name: String, path: PathBuf) -> Self {
|
||||
Self {
|
||||
name,
|
||||
path,
|
||||
scenes: Vec::new(),
|
||||
assets: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(path: &PathBuf) -> Result<Self, String> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read project file: {}", e))?;
|
||||
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse project file: {}", e))
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
let mut project_file = self.path.clone();
|
||||
project_file.push("project.json");
|
||||
|
||||
let content = serde_json::to_string_pretty(self)
|
||||
.map_err(|e| format!("Failed to serialize project: {}", e))?;
|
||||
|
||||
std::fs::write(&project_file, content)
|
||||
.map_err(|e| format!("Failed to write project file: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
94
packages/editor-app/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"productName": "ECS Framework Editor",
|
||||
"version": "1.0.3",
|
||||
"identifier": "com.esengine.editor",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": ""
|
||||
},
|
||||
"macOS": {
|
||||
"frameworks": [],
|
||||
"minimumSystemVersion": "10.13",
|
||||
"exceptionDomain": "",
|
||||
"signingIdentity": null,
|
||||
"providerShortName": null,
|
||||
"entitlements": null
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "ECS Framework Editor",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"center": true,
|
||||
"skipTaskbar": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": {
|
||||
"allow": [
|
||||
"**"
|
||||
]
|
||||
}
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"identifier": "main",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:default",
|
||||
"dialog:default",
|
||||
"updater:default",
|
||||
"updater:allow-check",
|
||||
"updater:allow-download",
|
||||
"updater:allow-install"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"updater": {
|
||||
"active": true,
|
||||
"endpoints": [
|
||||
"https://github.com/esengine/ecs-framework/releases/latest/download/latest.json"
|
||||
],
|
||||
"dialog": true,
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFDQjNFNDIxREFBODNDNkMKUldSc1BLamFJZVN6SEJIRXRWWEovVXRta08yNWFkZmtKNnZoSHFmbi9ZdGxubUMzSHJaN3J0VEcK"
|
||||
}
|
||||
}
|
||||
}
|
||||
735
packages/editor-app/src/App.tsx
Normal file
@@ -0,0 +1,735 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Core, Scene } from '@esengine/ecs-framework';
|
||||
import { EditorPluginManager, UIRegistry, MessageHub, SerializerRegistry, EntityStoreService, ComponentRegistry, LocaleService, ProjectService, ComponentDiscoveryService, PropertyMetadataService, LogService, SettingsRegistry, SceneManagerService } from '@esengine/editor-core';
|
||||
import { SceneInspectorPlugin } from './plugins/SceneInspectorPlugin';
|
||||
import { ProfilerPlugin } from './plugins/ProfilerPlugin';
|
||||
import { EditorAppearancePlugin } from './plugins/EditorAppearancePlugin';
|
||||
import { StartupPage } from './components/StartupPage';
|
||||
import { SceneHierarchy } from './components/SceneHierarchy';
|
||||
import { EntityInspector } from './components/EntityInspector';
|
||||
import { AssetBrowser } from './components/AssetBrowser';
|
||||
import { ConsolePanel } from './components/ConsolePanel';
|
||||
import { PluginManagerWindow } from './components/PluginManagerWindow';
|
||||
import { ProfilerWindow } from './components/ProfilerWindow';
|
||||
import { PortManager } from './components/PortManager';
|
||||
import { SettingsWindow } from './components/SettingsWindow';
|
||||
import { AboutDialog } from './components/AboutDialog';
|
||||
import { ErrorDialog } from './components/ErrorDialog';
|
||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||
import { Viewport } from './components/Viewport';
|
||||
import { MenuBar } from './components/MenuBar';
|
||||
import { DockContainer, DockablePanel } from './components/DockContainer';
|
||||
import { TauriAPI } from './api/tauri';
|
||||
import { TauriFileAPI } from './adapters/TauriFileAPI';
|
||||
import { SettingsService } from './services/SettingsService';
|
||||
import { checkForUpdatesOnStartup } from './utils/updater';
|
||||
import { useLocale } from './hooks/useLocale';
|
||||
import { en, zh } from './locales';
|
||||
import { Loader2, Globe } from 'lucide-react';
|
||||
import './styles/App.css';
|
||||
|
||||
const coreInstance = Core.create({ debug: true });
|
||||
|
||||
const localeService = new LocaleService();
|
||||
localeService.registerTranslations('en', en);
|
||||
localeService.registerTranslations('zh', zh);
|
||||
Core.services.registerInstance(LocaleService, localeService);
|
||||
|
||||
function App() {
|
||||
const initRef = useRef(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [projectLoaded, setProjectLoaded] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingMessage, setLoadingMessage] = useState('');
|
||||
const [currentProjectPath, setCurrentProjectPath] = useState<string | null>(null);
|
||||
const [pluginManager, setPluginManager] = useState<EditorPluginManager | null>(null);
|
||||
const [entityStore, setEntityStore] = useState<EntityStoreService | null>(null);
|
||||
const [messageHub, setMessageHub] = useState<MessageHub | null>(null);
|
||||
const [logService, setLogService] = useState<LogService | null>(null);
|
||||
const [uiRegistry, setUiRegistry] = useState<UIRegistry | null>(null);
|
||||
const [settingsRegistry, setSettingsRegistry] = useState<SettingsRegistry | null>(null);
|
||||
const [sceneManager, setSceneManager] = useState<SceneManagerService | null>(null);
|
||||
const { t, locale, changeLocale } = useLocale();
|
||||
const [status, setStatus] = useState(t('header.status.initializing'));
|
||||
const [panels, setPanels] = useState<DockablePanel[]>([]);
|
||||
const [showPluginManager, setShowPluginManager] = useState(false);
|
||||
const [showProfiler, setShowProfiler] = useState(false);
|
||||
const [showPortManager, setShowPortManager] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0);
|
||||
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
|
||||
const [isProfilerMode, setIsProfilerMode] = useState(false);
|
||||
const [errorDialog, setErrorDialog] = useState<{ title: string; message: string } | null>(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText: string;
|
||||
cancelText: string;
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 禁用默认右键菜单
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
document.addEventListener('contextmenu', handleContextMenu);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('contextmenu', handleContextMenu);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (messageHub) {
|
||||
const unsubscribeEnabled = messageHub.subscribe('plugin:enabled', () => {
|
||||
setPluginUpdateTrigger(prev => prev + 1);
|
||||
});
|
||||
|
||||
const unsubscribeDisabled = messageHub.subscribe('plugin:disabled', () => {
|
||||
setPluginUpdateTrigger(prev => prev + 1);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeEnabled();
|
||||
unsubscribeDisabled();
|
||||
};
|
||||
}
|
||||
}, [messageHub]);
|
||||
|
||||
// 监听远程连接状态
|
||||
useEffect(() => {
|
||||
const checkConnection = () => {
|
||||
const profilerService = (window as any).__PROFILER_SERVICE__;
|
||||
if (profilerService && profilerService.isConnected()) {
|
||||
if (!isRemoteConnected) {
|
||||
setIsRemoteConnected(true);
|
||||
setStatus(t('header.status.remoteConnected'));
|
||||
}
|
||||
} else {
|
||||
if (isRemoteConnected) {
|
||||
setIsRemoteConnected(false);
|
||||
if (projectLoaded) {
|
||||
const componentRegistry = Core.services.resolve(ComponentRegistry);
|
||||
const componentCount = componentRegistry?.getAllComponents().length || 0;
|
||||
setStatus(t('header.status.projectOpened') + (componentCount > 0 ? ` (${componentCount} components registered)` : ''));
|
||||
} else {
|
||||
setStatus(t('header.status.ready'));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkConnection();
|
||||
const interval = setInterval(checkConnection, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [projectLoaded, isRemoteConnected, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeEditor = async () => {
|
||||
// 使用 ref 防止 React StrictMode 的双重调用
|
||||
if (initRef.current) {
|
||||
return;
|
||||
}
|
||||
initRef.current = true;
|
||||
|
||||
try {
|
||||
(window as any).__ECS_FRAMEWORK__ = await import('@esengine/ecs-framework');
|
||||
|
||||
const editorScene = new Scene();
|
||||
Core.setScene(editorScene);
|
||||
|
||||
const uiRegistry = new UIRegistry();
|
||||
const messageHub = new MessageHub();
|
||||
const serializerRegistry = new SerializerRegistry();
|
||||
const entityStore = new EntityStoreService(messageHub);
|
||||
const componentRegistry = new ComponentRegistry();
|
||||
const fileAPI = new TauriFileAPI();
|
||||
const projectService = new ProjectService(messageHub, fileAPI);
|
||||
const componentDiscovery = new ComponentDiscoveryService(messageHub);
|
||||
const propertyMetadata = new PropertyMetadataService();
|
||||
const logService = new LogService();
|
||||
const settingsRegistry = new SettingsRegistry();
|
||||
const sceneManagerService = new SceneManagerService(messageHub, fileAPI, projectService);
|
||||
|
||||
// 监听远程日志事件
|
||||
window.addEventListener('profiler:remote-log', ((event: CustomEvent) => {
|
||||
const { level, message, timestamp, clientId } = event.detail;
|
||||
logService.addRemoteLog(level, message, timestamp, clientId);
|
||||
}) as EventListener);
|
||||
|
||||
Core.services.registerInstance(UIRegistry, uiRegistry);
|
||||
Core.services.registerInstance(MessageHub, messageHub);
|
||||
Core.services.registerInstance(SerializerRegistry, serializerRegistry);
|
||||
Core.services.registerInstance(EntityStoreService, entityStore);
|
||||
Core.services.registerInstance(ComponentRegistry, componentRegistry);
|
||||
Core.services.registerInstance(ProjectService, projectService);
|
||||
Core.services.registerInstance(ComponentDiscoveryService, componentDiscovery);
|
||||
Core.services.registerInstance(PropertyMetadataService, propertyMetadata);
|
||||
Core.services.registerInstance(LogService, logService);
|
||||
Core.services.registerInstance(SettingsRegistry, settingsRegistry);
|
||||
Core.services.registerInstance(SceneManagerService, sceneManagerService);
|
||||
|
||||
const pluginMgr = new EditorPluginManager();
|
||||
pluginMgr.initialize(coreInstance, Core.services);
|
||||
Core.services.registerInstance(EditorPluginManager, pluginMgr);
|
||||
|
||||
await pluginMgr.installEditor(new SceneInspectorPlugin());
|
||||
await pluginMgr.installEditor(new ProfilerPlugin());
|
||||
await pluginMgr.installEditor(new EditorAppearancePlugin());
|
||||
|
||||
messageHub.subscribe('ui:openWindow', (data: any) => {
|
||||
if (data.windowId === 'profiler') {
|
||||
setShowProfiler(true);
|
||||
} else if (data.windowId === 'pluginManager') {
|
||||
setShowPluginManager(true);
|
||||
}
|
||||
});
|
||||
|
||||
await TauriAPI.greet('Developer');
|
||||
|
||||
setInitialized(true);
|
||||
setPluginManager(pluginMgr);
|
||||
setEntityStore(entityStore);
|
||||
setMessageHub(messageHub);
|
||||
setLogService(logService);
|
||||
setUiRegistry(uiRegistry);
|
||||
setSettingsRegistry(settingsRegistry);
|
||||
setSceneManager(sceneManagerService);
|
||||
setStatus(t('header.status.ready'));
|
||||
|
||||
// Check for updates on startup (after 3 seconds)
|
||||
checkForUpdatesOnStartup();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize editor:', error);
|
||||
setStatus(t('header.status.failed'));
|
||||
}
|
||||
};
|
||||
|
||||
initializeEditor();
|
||||
}, []);
|
||||
|
||||
const handleOpenRecentProject = async (projectPath: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setLoadingMessage(locale === 'zh' ? '步骤 1/2: 打开项目配置...' : 'Step 1/2: Opening project config...');
|
||||
|
||||
const projectService = Core.services.resolve(ProjectService);
|
||||
|
||||
if (!projectService) {
|
||||
console.error('Required services not available');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await projectService.openProject(projectPath);
|
||||
|
||||
await fetch('/@user-project-set-path', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: projectPath })
|
||||
});
|
||||
|
||||
setStatus(t('header.status.projectOpened'));
|
||||
|
||||
setLoadingMessage(locale === 'zh' ? '步骤 2/2: 加载场景...' : 'Step 2/2: Loading scene...');
|
||||
|
||||
const sceneManagerService = Core.services.resolve(SceneManagerService);
|
||||
const scenesPath = projectService.getScenesPath();
|
||||
if (scenesPath && sceneManagerService) {
|
||||
try {
|
||||
const sceneFiles = await TauriAPI.scanDirectory(scenesPath, '*.ecs');
|
||||
|
||||
if (sceneFiles.length > 0) {
|
||||
const defaultScenePath = projectService.getDefaultScenePath();
|
||||
const sceneToLoad = sceneFiles.find(f => f === defaultScenePath) || sceneFiles[0];
|
||||
|
||||
await sceneManagerService.openScene(sceneToLoad);
|
||||
} else {
|
||||
await sceneManagerService.newScene();
|
||||
}
|
||||
} catch (error) {
|
||||
await sceneManagerService.newScene();
|
||||
}
|
||||
}
|
||||
|
||||
const settings = SettingsService.getInstance();
|
||||
settings.addRecentProject(projectPath);
|
||||
|
||||
setCurrentProjectPath(projectPath);
|
||||
setProjectLoaded(true);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to open project:', error);
|
||||
setStatus(t('header.status.failed'));
|
||||
setIsLoading(false);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
setErrorDialog({
|
||||
title: locale === 'zh' ? '打开项目失败' : 'Failed to Open Project',
|
||||
message: locale === 'zh'
|
||||
? `无法打开项目:\n${errorMessage}`
|
||||
: `Failed to open project:\n${errorMessage}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenProject = async () => {
|
||||
try {
|
||||
const projectPath = await TauriAPI.openProjectDialog();
|
||||
if (!projectPath) return;
|
||||
|
||||
await handleOpenRecentProject(projectPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to open project dialog:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
let selectedProjectPath: string | null = null;
|
||||
|
||||
try {
|
||||
selectedProjectPath = await TauriAPI.openProjectDialog();
|
||||
if (!selectedProjectPath) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setLoadingMessage(locale === 'zh' ? '正在创建项目...' : 'Creating project...');
|
||||
|
||||
const projectService = Core.services.resolve(ProjectService);
|
||||
if (!projectService) {
|
||||
console.error('ProjectService not available');
|
||||
setIsLoading(false);
|
||||
setErrorDialog({
|
||||
title: locale === 'zh' ? '创建项目失败' : 'Failed to Create Project',
|
||||
message: locale === 'zh' ? '项目服务不可用,请重启编辑器' : 'Project service is not available. Please restart the editor.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await projectService.createProject(selectedProjectPath);
|
||||
|
||||
setLoadingMessage(locale === 'zh' ? '项目创建成功,正在打开...' : 'Project created, opening...');
|
||||
|
||||
await handleOpenRecentProject(selectedProjectPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error);
|
||||
setIsLoading(false);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const pathToOpen = selectedProjectPath;
|
||||
|
||||
if (errorMessage.includes('already exists') && pathToOpen) {
|
||||
setConfirmDialog({
|
||||
title: locale === 'zh' ? '项目已存在' : 'Project Already Exists',
|
||||
message: locale === 'zh'
|
||||
? '该目录下已存在 ECS 项目,是否要打开该项目?'
|
||||
: 'An ECS project already exists in this directory. Do you want to open it?',
|
||||
confirmText: locale === 'zh' ? '打开项目' : 'Open Project',
|
||||
cancelText: locale === 'zh' ? '取消' : 'Cancel',
|
||||
onConfirm: () => {
|
||||
setConfirmDialog(null);
|
||||
setIsLoading(true);
|
||||
setLoadingMessage(locale === 'zh' ? '正在打开项目...' : 'Opening project...');
|
||||
handleOpenRecentProject(pathToOpen).catch((err) => {
|
||||
console.error('Failed to open project:', err);
|
||||
setIsLoading(false);
|
||||
setErrorDialog({
|
||||
title: locale === 'zh' ? '打开项目失败' : 'Failed to Open Project',
|
||||
message: locale === 'zh'
|
||||
? `无法打开项目:\n${err instanceof Error ? err.message : String(err)}`
|
||||
: `Failed to open project:\n${err instanceof Error ? err.message : String(err)}`
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setStatus(locale === 'zh' ? '创建项目失败' : 'Failed to create project');
|
||||
setErrorDialog({
|
||||
title: locale === 'zh' ? '创建项目失败' : 'Failed to Create Project',
|
||||
message: locale === 'zh'
|
||||
? `无法创建项目:\n${errorMessage}`
|
||||
: `Failed to create project:\n${errorMessage}`
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfilerMode = async () => {
|
||||
setIsProfilerMode(true);
|
||||
setProjectLoaded(true);
|
||||
setStatus(t('header.status.profilerMode') || 'Profiler Mode - Waiting for connection...');
|
||||
};
|
||||
|
||||
const handleNewScene = async () => {
|
||||
if (!sceneManager) {
|
||||
console.error('SceneManagerService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sceneManager.newScene();
|
||||
setStatus(locale === 'zh' ? '已创建新场景' : 'New scene created');
|
||||
} catch (error) {
|
||||
console.error('Failed to create new scene:', error);
|
||||
setStatus(locale === 'zh' ? '创建场景失败' : 'Failed to create scene');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenScene = async () => {
|
||||
if (!sceneManager) {
|
||||
console.error('SceneManagerService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sceneManager.openScene();
|
||||
const sceneState = sceneManager.getSceneState();
|
||||
setStatus(locale === 'zh' ? `已打开场景: ${sceneState.sceneName}` : `Scene opened: ${sceneState.sceneName}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to open scene:', error);
|
||||
setStatus(locale === 'zh' ? '打开场景失败' : 'Failed to open scene');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenSceneByPath = useCallback(async (scenePath: string) => {
|
||||
console.log('[App] handleOpenSceneByPath called with:', scenePath);
|
||||
|
||||
if (!sceneManager) {
|
||||
console.error('SceneManagerService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[App] Opening scene:', scenePath);
|
||||
await sceneManager.openScene(scenePath);
|
||||
const sceneState = sceneManager.getSceneState();
|
||||
console.log('[App] Scene opened, state:', sceneState);
|
||||
setStatus(locale === 'zh' ? `已打开场景: ${sceneState.sceneName}` : `Scene opened: ${sceneState.sceneName}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to open scene:', error);
|
||||
setStatus(locale === 'zh' ? '打开场景失败' : 'Failed to open scene');
|
||||
setErrorDialog({
|
||||
title: locale === 'zh' ? '打开场景失败' : 'Failed to Open Scene',
|
||||
message: locale === 'zh'
|
||||
? `无法打开场景:\n${error instanceof Error ? error.message : String(error)}`
|
||||
: `Failed to open scene:\n${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
}
|
||||
}, [sceneManager, locale]);
|
||||
|
||||
const handleSaveScene = async () => {
|
||||
if (!sceneManager) {
|
||||
console.error('SceneManagerService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sceneManager.saveScene();
|
||||
const sceneState = sceneManager.getSceneState();
|
||||
setStatus(locale === 'zh' ? `已保存场景: ${sceneState.sceneName}` : `Scene saved: ${sceneState.sceneName}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to save scene:', error);
|
||||
setStatus(locale === 'zh' ? '保存场景失败' : 'Failed to save scene');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSceneAs = async () => {
|
||||
if (!sceneManager) {
|
||||
console.error('SceneManagerService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sceneManager.saveSceneAs();
|
||||
const sceneState = sceneManager.getSceneState();
|
||||
setStatus(locale === 'zh' ? `已保存场景: ${sceneState.sceneName}` : `Scene saved: ${sceneState.sceneName}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to save scene as:', error);
|
||||
setStatus(locale === 'zh' ? '另存场景失败' : 'Failed to save scene as');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportScene = async () => {
|
||||
if (!sceneManager) {
|
||||
console.error('SceneManagerService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sceneManager.exportScene();
|
||||
const sceneState = sceneManager.getSceneState();
|
||||
setStatus(locale === 'zh' ? `已导出场景: ${sceneState.sceneName}` : `Scene exported: ${sceneState.sceneName}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to export scene:', error);
|
||||
setStatus(locale === 'zh' ? '导出场景失败' : 'Failed to export scene');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseProject = () => {
|
||||
setProjectLoaded(false);
|
||||
setCurrentProjectPath(null);
|
||||
setIsProfilerMode(false);
|
||||
setStatus(t('header.status.ready'));
|
||||
};
|
||||
|
||||
const handleExit = () => {
|
||||
window.close();
|
||||
};
|
||||
|
||||
const handleLocaleChange = () => {
|
||||
const newLocale = locale === 'en' ? 'zh' : 'en';
|
||||
changeLocale(newLocale);
|
||||
};
|
||||
|
||||
const handleToggleDevtools = async () => {
|
||||
try {
|
||||
await TauriAPI.toggleDevtools();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle devtools:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAbout = () => {
|
||||
setShowAbout(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (projectLoaded && entityStore && messageHub && logService && uiRegistry && pluginManager) {
|
||||
let corePanels: DockablePanel[];
|
||||
|
||||
if (isProfilerMode) {
|
||||
corePanels = [
|
||||
{
|
||||
id: 'scene-hierarchy',
|
||||
title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
|
||||
position: 'left',
|
||||
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'inspector',
|
||||
title: locale === 'zh' ? '检视器' : 'Inspector',
|
||||
position: 'right',
|
||||
content: <EntityInspector entityStore={entityStore} messageHub={messageHub} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'console',
|
||||
title: locale === 'zh' ? '控制台' : 'Console',
|
||||
position: 'bottom',
|
||||
content: <ConsolePanel logService={logService} />,
|
||||
closable: false
|
||||
}
|
||||
];
|
||||
} else {
|
||||
corePanels = [
|
||||
{
|
||||
id: 'scene-hierarchy',
|
||||
title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
|
||||
position: 'left',
|
||||
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'inspector',
|
||||
title: locale === 'zh' ? '检视器' : 'Inspector',
|
||||
position: 'right',
|
||||
content: <EntityInspector entityStore={entityStore} messageHub={messageHub} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'viewport',
|
||||
title: locale === 'zh' ? '视口' : 'Viewport',
|
||||
position: 'center',
|
||||
content: <Viewport locale={locale} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'assets',
|
||||
title: locale === 'zh' ? '资产' : 'Assets',
|
||||
position: 'bottom',
|
||||
content: <AssetBrowser projectPath={currentProjectPath} locale={locale} onOpenScene={handleOpenSceneByPath} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'console',
|
||||
title: locale === 'zh' ? '控制台' : 'Console',
|
||||
position: 'bottom',
|
||||
content: <ConsolePanel logService={logService} />,
|
||||
closable: false
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
const enabledPlugins = pluginManager.getAllPluginMetadata()
|
||||
.filter(p => p.enabled)
|
||||
.map(p => p.name);
|
||||
|
||||
const pluginPanels: DockablePanel[] = uiRegistry.getAllPanels()
|
||||
.filter(panelDesc => {
|
||||
if (!panelDesc.component) {
|
||||
return false;
|
||||
}
|
||||
return enabledPlugins.some(pluginName => {
|
||||
const plugin = pluginManager.getEditorPlugin(pluginName);
|
||||
if (plugin && plugin.registerPanels) {
|
||||
const pluginPanels = plugin.registerPanels();
|
||||
return pluginPanels.some(p => p.id === panelDesc.id);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
})
|
||||
.map(panelDesc => {
|
||||
const Component = panelDesc.component;
|
||||
return {
|
||||
id: panelDesc.id,
|
||||
title: (panelDesc as any).titleZh && locale === 'zh' ? (panelDesc as any).titleZh : panelDesc.title,
|
||||
position: panelDesc.position as any,
|
||||
content: <Component />,
|
||||
closable: panelDesc.closable ?? true
|
||||
};
|
||||
});
|
||||
|
||||
console.log('[App] Loading plugin panels:', pluginPanels);
|
||||
setPanels([...corePanels, ...pluginPanels]);
|
||||
}
|
||||
}, [projectLoaded, entityStore, messageHub, logService, uiRegistry, pluginManager, locale, currentProjectPath, t, pluginUpdateTrigger, isProfilerMode, handleOpenSceneByPath]);
|
||||
|
||||
const handlePanelMove = (panelId: string, newPosition: any) => {
|
||||
setPanels(prevPanels =>
|
||||
prevPanels.map(panel =>
|
||||
panel.id === panelId ? { ...panel, position: newPosition } : panel
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
if (!initialized) {
|
||||
return (
|
||||
<div className="editor-loading">
|
||||
<Loader2 size={32} className="animate-spin" />
|
||||
<h2>Loading Editor...</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectLoaded) {
|
||||
const settings = SettingsService.getInstance();
|
||||
const recentProjects = settings.getRecentProjects();
|
||||
|
||||
return (
|
||||
<>
|
||||
<StartupPage
|
||||
onOpenProject={handleOpenProject}
|
||||
onCreateProject={handleCreateProject}
|
||||
onOpenRecentProject={handleOpenRecentProject}
|
||||
onProfilerMode={handleProfilerMode}
|
||||
recentProjects={recentProjects}
|
||||
locale={locale}
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="loading-overlay">
|
||||
<div className="loading-content">
|
||||
<Loader2 size={40} className="animate-spin" />
|
||||
<p className="loading-message">{loadingMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{errorDialog && (
|
||||
<ErrorDialog
|
||||
title={errorDialog.title}
|
||||
message={errorDialog.message}
|
||||
onClose={() => setErrorDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{confirmDialog && (
|
||||
<ConfirmDialog
|
||||
title={confirmDialog.title}
|
||||
message={confirmDialog.message}
|
||||
confirmText={confirmDialog.confirmText}
|
||||
cancelText={confirmDialog.cancelText}
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
onCancel={() => setConfirmDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="editor-container">
|
||||
<div className={`editor-header ${isRemoteConnected ? 'remote-connected' : ''}`}>
|
||||
<MenuBar
|
||||
locale={locale}
|
||||
uiRegistry={uiRegistry || undefined}
|
||||
messageHub={messageHub || undefined}
|
||||
pluginManager={pluginManager || undefined}
|
||||
onNewScene={handleNewScene}
|
||||
onOpenScene={handleOpenScene}
|
||||
onSaveScene={handleSaveScene}
|
||||
onSaveSceneAs={handleSaveSceneAs}
|
||||
onOpenProject={handleOpenProject}
|
||||
onCloseProject={handleCloseProject}
|
||||
onExit={handleExit}
|
||||
onOpenPluginManager={() => setShowPluginManager(true)}
|
||||
onOpenProfiler={() => setShowProfiler(true)}
|
||||
onOpenPortManager={() => setShowPortManager(true)}
|
||||
onOpenSettings={() => setShowSettings(true)}
|
||||
onToggleDevtools={handleToggleDevtools}
|
||||
onOpenAbout={handleOpenAbout}
|
||||
/>
|
||||
<div className="header-right">
|
||||
<button onClick={handleLocaleChange} className="toolbar-btn locale-btn" title={locale === 'en' ? '切换到中文' : 'Switch to English'}>
|
||||
<Globe size={14} />
|
||||
</button>
|
||||
<span className="status">{status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-content">
|
||||
<DockContainer panels={panels} onPanelMove={handlePanelMove} />
|
||||
</div>
|
||||
|
||||
<div className="editor-footer">
|
||||
<span>{t('footer.plugins')}: {pluginManager?.getAllEditorPlugins().length ?? 0}</span>
|
||||
<span>{t('footer.entities')}: {entityStore?.getAllEntities().length ?? 0}</span>
|
||||
<span>{t('footer.core')}: {initialized ? t('footer.active') : t('footer.inactive')}</span>
|
||||
</div>
|
||||
|
||||
{showPluginManager && pluginManager && (
|
||||
<PluginManagerWindow
|
||||
pluginManager={pluginManager}
|
||||
onClose={() => setShowPluginManager(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showProfiler && (
|
||||
<ProfilerWindow onClose={() => setShowProfiler(false)} />
|
||||
)}
|
||||
|
||||
{showPortManager && (
|
||||
<PortManager onClose={() => setShowPortManager(false)} />
|
||||
)}
|
||||
|
||||
{showSettings && settingsRegistry && (
|
||||
<SettingsWindow onClose={() => setShowSettings(false)} settingsRegistry={settingsRegistry} />
|
||||
)}
|
||||
|
||||
{showAbout && (
|
||||
<AboutDialog onClose={() => setShowAbout(false)} locale={locale} />
|
||||
)}
|
||||
|
||||
{errorDialog && (
|
||||
<ErrorDialog
|
||||
title={errorDialog.title}
|
||||
message={errorDialog.message}
|
||||
onClose={() => setErrorDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
41
packages/editor-app/src/adapters/TauriFileAPI.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { IFileAPI } from '@esengine/editor-core';
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
|
||||
/**
|
||||
* Tauri 文件 API 适配器
|
||||
*
|
||||
* 实现 IFileAPI 接口,连接 editor-core 和 Tauri 后端
|
||||
*/
|
||||
export class TauriFileAPI implements IFileAPI {
|
||||
public async openSceneDialog(): Promise<string | null> {
|
||||
return await TauriAPI.openSceneDialog();
|
||||
}
|
||||
|
||||
public async saveSceneDialog(defaultName?: string): Promise<string | null> {
|
||||
return await TauriAPI.saveSceneDialog(defaultName);
|
||||
}
|
||||
|
||||
public async readFileContent(path: string): Promise<string> {
|
||||
return await TauriAPI.readFileContent(path);
|
||||
}
|
||||
|
||||
public async saveProject(path: string, data: string): Promise<void> {
|
||||
return await TauriAPI.saveProject(path, data);
|
||||
}
|
||||
|
||||
public async exportBinary(data: Uint8Array, path: string): Promise<void> {
|
||||
return await TauriAPI.exportBinary(data, path);
|
||||
}
|
||||
|
||||
public async createDirectory(path: string): Promise<void> {
|
||||
return await TauriAPI.createDirectory(path);
|
||||
}
|
||||
|
||||
public async writeFileContent(path: string, content: string): Promise<void> {
|
||||
return await TauriAPI.writeFileContent(path, content);
|
||||
}
|
||||
|
||||
public async pathExists(path: string): Promise<boolean> {
|
||||
return await TauriAPI.pathExists(path);
|
||||
}
|
||||
}
|
||||
140
packages/editor-app/src/api/tauri.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
/**
|
||||
* Tauri IPC 通信层
|
||||
*/
|
||||
export class TauriAPI {
|
||||
/**
|
||||
* 打招呼(测试命令)
|
||||
*/
|
||||
static async greet(name: string): Promise<string> {
|
||||
return await invoke<string>('greet', { name });
|
||||
}
|
||||
|
||||
static async openProjectDialog(): Promise<string | null> {
|
||||
return await invoke<string | null>('open_project_dialog');
|
||||
}
|
||||
|
||||
static async openProject(path: string): Promise<string> {
|
||||
return await invoke<string>('open_project', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存项目
|
||||
*/
|
||||
static async saveProject(path: string, data: string): Promise<void> {
|
||||
return await invoke<void>('save_project', { path, data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出二进制数据
|
||||
*/
|
||||
static async exportBinary(data: Uint8Array, outputPath: string): Promise<void> {
|
||||
return await invoke<void>('export_binary', {
|
||||
data: Array.from(data),
|
||||
outputPath
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描目录查找匹配模式的文件
|
||||
*/
|
||||
static async scanDirectory(path: string, pattern: string): Promise<string[]> {
|
||||
return await invoke<string[]>('scan_directory', { path, pattern });
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容
|
||||
*/
|
||||
static async readFileContent(path: string): Promise<string> {
|
||||
return await invoke<string>('read_file_content', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出目录内容
|
||||
*/
|
||||
static async listDirectory(path: string): Promise<DirectoryEntry[]> {
|
||||
return await invoke<DirectoryEntry[]>('list_directory', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置项目基础路径,用于 Custom Protocol
|
||||
*/
|
||||
static async setProjectBasePath(path: string): Promise<void> {
|
||||
return await invoke<void>('set_project_base_path', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换开发者工具(仅在debug模式下可用)
|
||||
*/
|
||||
static async toggleDevtools(): Promise<void> {
|
||||
return await invoke<void>('toggle_devtools');
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开保存场景对话框
|
||||
* @param defaultName 默认文件名(可选)
|
||||
* @returns 用户选择的文件路径,取消则返回 null
|
||||
*/
|
||||
static async saveSceneDialog(defaultName?: string): Promise<string | null> {
|
||||
return await invoke<string | null>('save_scene_dialog', { defaultName });
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开场景文件选择对话框
|
||||
* @returns 用户选择的文件路径,取消则返回 null
|
||||
*/
|
||||
static async openSceneDialog(): Promise<string | null> {
|
||||
return await invoke<string | null>('open_scene_dialog');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建目录
|
||||
* @param path 目录路径
|
||||
*/
|
||||
static async createDirectory(path: string): Promise<void> {
|
||||
return await invoke<void>('create_directory', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入文件内容
|
||||
* @param path 文件路径
|
||||
* @param content 文件内容
|
||||
*/
|
||||
static async writeFileContent(path: string, content: string): Promise<void> {
|
||||
return await invoke<void>('write_file_content', { path, content });
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查路径是否存在
|
||||
* @param path 文件或目录路径
|
||||
* @returns 路径是否存在
|
||||
*/
|
||||
static async pathExists(path: string): Promise<boolean> {
|
||||
return await invoke<boolean>('path_exists', { path });
|
||||
}
|
||||
}
|
||||
|
||||
export interface DirectoryEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目信息
|
||||
*/
|
||||
export interface ProjectInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器配置
|
||||
*/
|
||||
export interface EditorConfig {
|
||||
theme: string;
|
||||
autoSave: boolean;
|
||||
recentProjects: string[];
|
||||
}
|
||||
213
packages/editor-app/src/components/AboutDialog.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, RefreshCw, Check, AlertCircle, Download } from 'lucide-react';
|
||||
import { checkForUpdates } from '../utils/updater';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import '../styles/AboutDialog.css';
|
||||
|
||||
interface AboutDialogProps {
|
||||
onClose: () => void;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) {
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'available' | 'latest' | 'error'>('idle');
|
||||
const [version, setVersion] = useState<string>('1.0.0');
|
||||
const [newVersion, setNewVersion] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch version on mount
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const currentVersion = await getVersion();
|
||||
setVersion(currentVersion);
|
||||
} catch (error) {
|
||||
console.error('Failed to get version:', error);
|
||||
}
|
||||
};
|
||||
fetchVersion();
|
||||
}, []);
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
en: {
|
||||
title: 'About ECS Framework Editor',
|
||||
version: 'Version',
|
||||
description: 'High-performance ECS framework editor for game development',
|
||||
checkUpdate: 'Check for Updates',
|
||||
checking: 'Checking...',
|
||||
updateAvailable: 'New version available',
|
||||
latest: 'You are using the latest version',
|
||||
error: 'Failed to check for updates',
|
||||
download: 'Download Update',
|
||||
close: 'Close',
|
||||
copyright: '© 2025 ESEngine. All rights reserved.',
|
||||
website: 'Website',
|
||||
github: 'GitHub'
|
||||
},
|
||||
zh: {
|
||||
title: '关于 ECS Framework Editor',
|
||||
version: '版本',
|
||||
description: '高性能 ECS 框架编辑器,用于游戏开发',
|
||||
checkUpdate: '检查更新',
|
||||
checking: '检查中...',
|
||||
updateAvailable: '发现新版本',
|
||||
latest: '您正在使用最新版本',
|
||||
error: '检查更新失败',
|
||||
download: '下载更新',
|
||||
close: '关闭',
|
||||
copyright: '© 2025 ESEngine. 保留所有权利。',
|
||||
website: '官网',
|
||||
github: 'GitHub'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || key;
|
||||
};
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
setChecking(true);
|
||||
setUpdateStatus('checking');
|
||||
|
||||
try {
|
||||
const currentVersion = await getVersion();
|
||||
setVersion(currentVersion);
|
||||
|
||||
// 使用我们的 updater 工具检查更新
|
||||
const result = await checkForUpdates(false);
|
||||
|
||||
if (result.error) {
|
||||
setUpdateStatus('error');
|
||||
} else if (result.available) {
|
||||
setUpdateStatus('available');
|
||||
if (result.version) {
|
||||
setNewVersion(result.version);
|
||||
}
|
||||
} else {
|
||||
setUpdateStatus('latest');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Check update failed:', error);
|
||||
setUpdateStatus('error');
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (updateStatus) {
|
||||
case 'checking':
|
||||
return <RefreshCw size={16} className="animate-spin" />;
|
||||
case 'available':
|
||||
return <Download size={16} className="status-available" />;
|
||||
case 'latest':
|
||||
return <Check size={16} className="status-latest" />;
|
||||
case 'error':
|
||||
return <AlertCircle size={16} className="status-error" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (updateStatus) {
|
||||
case 'checking':
|
||||
return t('checking');
|
||||
case 'available':
|
||||
return `${t('updateAvailable')} (v${newVersion})`;
|
||||
case 'latest':
|
||||
return t('latest');
|
||||
case 'error':
|
||||
return t('error');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenGithub = async () => {
|
||||
try {
|
||||
await open('https://github.com/esengine/ecs-framework');
|
||||
} catch (error) {
|
||||
console.error('Failed to open GitHub link:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="about-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="about-header">
|
||||
<h2>{t('title')}</h2>
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="about-content">
|
||||
<div className="about-logo">
|
||||
<div className="logo-placeholder">ECS</div>
|
||||
</div>
|
||||
|
||||
<div className="about-info">
|
||||
<h3>ECS Framework Editor</h3>
|
||||
<p className="about-version">
|
||||
{t('version')}: Editor {version}
|
||||
</p>
|
||||
<p className="about-description">
|
||||
{t('description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="about-update">
|
||||
<button
|
||||
className="update-btn"
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? (
|
||||
<>
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
<span>{t('checking')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw size={16} />
|
||||
<span>{t('checkUpdate')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{updateStatus !== 'idle' && (
|
||||
<div className={`update-status status-${updateStatus}`}>
|
||||
{getStatusIcon()}
|
||||
<span>{getStatusText()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="about-links">
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleOpenGithub();
|
||||
}}
|
||||
className="about-link"
|
||||
>
|
||||
{t('github')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="about-footer">
|
||||
<p>{t('copyright')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="about-actions">
|
||||
<button className="btn-primary" onClick={onClose}>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
311
packages/editor-app/src/components/AssetBrowser.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||
import { FileTree } from './FileTree';
|
||||
import { ResizablePanel } from './ResizablePanel';
|
||||
import '../styles/AssetBrowser.css';
|
||||
|
||||
interface AssetItem {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'folder';
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
interface AssetBrowserProps {
|
||||
projectPath: string | null;
|
||||
locale: string;
|
||||
onOpenScene?: (scenePath: string) => void;
|
||||
}
|
||||
|
||||
type ViewMode = 'tree-split' | 'tree-only';
|
||||
|
||||
export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserProps) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('tree-split');
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [assets, setAssets] = useState<AssetItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
title: 'Assets',
|
||||
noProject: 'No project loaded',
|
||||
loading: 'Loading...',
|
||||
empty: 'No assets found',
|
||||
search: 'Search...',
|
||||
viewTreeSplit: 'Tree + List',
|
||||
viewTreeOnly: 'Tree Only',
|
||||
name: 'Name',
|
||||
type: 'Type',
|
||||
file: 'File',
|
||||
folder: 'Folder'
|
||||
},
|
||||
zh: {
|
||||
title: '资产',
|
||||
noProject: '没有加载项目',
|
||||
loading: '加载中...',
|
||||
empty: '没有找到资产',
|
||||
search: '搜索...',
|
||||
viewTreeSplit: '树形+列表',
|
||||
viewTreeOnly: '纯树形',
|
||||
name: '名称',
|
||||
type: '类型',
|
||||
file: '文件',
|
||||
folder: '文件夹'
|
||||
}
|
||||
};
|
||||
|
||||
const t = translations[locale as keyof typeof translations] || translations.en;
|
||||
|
||||
useEffect(() => {
|
||||
if (projectPath) {
|
||||
if (viewMode === 'tree-split') {
|
||||
loadAssets(projectPath);
|
||||
}
|
||||
} else {
|
||||
setAssets([]);
|
||||
setSelectedPath(null);
|
||||
}
|
||||
}, [projectPath, viewMode]);
|
||||
|
||||
// Listen for asset reveal requests
|
||||
useEffect(() => {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (!messageHub) return;
|
||||
|
||||
const unsubscribe = messageHub.subscribe('asset:reveal', (data: any) => {
|
||||
const filePath = data.path;
|
||||
if (filePath) {
|
||||
setSelectedPath(filePath);
|
||||
|
||||
if (viewMode === 'tree-split') {
|
||||
const lastSlashIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
|
||||
const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : null;
|
||||
if (dirPath) {
|
||||
loadAssets(dirPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [viewMode]);
|
||||
|
||||
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,
|
||||
type: entry.is_dir ? 'folder' as const : 'file' as const,
|
||||
extension
|
||||
};
|
||||
});
|
||||
|
||||
setAssets(assetItems);
|
||||
} catch (error) {
|
||||
console.error('Failed to load assets:', error);
|
||||
setAssets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTreeSelect = (path: string) => {
|
||||
setSelectedPath(path);
|
||||
if (viewMode === 'tree-split') {
|
||||
loadAssets(path);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssetClick = (asset: AssetItem) => {
|
||||
setSelectedPath(asset.path);
|
||||
};
|
||||
|
||||
const handleAssetDoubleClick = (asset: AssetItem) => {
|
||||
if (asset.type === 'file' && asset.extension === 'ecs') {
|
||||
if (onOpenScene) {
|
||||
onOpenScene(asset.path);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAssets = searchQuery
|
||||
? assets.filter(asset =>
|
||||
asset.type === 'file' && asset.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: assets.filter(asset => asset.type === 'file');
|
||||
|
||||
const getFileIcon = (extension?: string) => {
|
||||
switch (extension?.toLowerCase()) {
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className="asset-icon">
|
||||
<path d="M14 2H6C4.89543 2 4 2.89543 4 4V20C4 21.1046 4.89543 22 6 22H18C19.1046 22 20 21.1046 20 20V8L14 2Z" strokeWidth="2"/>
|
||||
<path d="M14 2V8H20" strokeWidth="2"/>
|
||||
<path d="M12 18L12 14M12 10L12 12" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
case 'json':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className="asset-icon">
|
||||
<path d="M14 2H6C4.89543 2 4 2.89543 4 4V20C4 21.1046 4.89543 22 6 22H18C19.1046 22 20 21.1046 20 20V8L14 2Z" strokeWidth="2"/>
|
||||
<path d="M14 2V8H20" strokeWidth="2"/>
|
||||
</svg>
|
||||
);
|
||||
case 'png':
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'gif':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className="asset-icon">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" strokeWidth="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor"/>
|
||||
<path d="M21 15L16 10L5 21" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className="asset-icon">
|
||||
<path d="M14 2H6C4.89543 2 4 2.89543 4 4V20C4 21.1046 4.89543 22 6 22H18C19.1046 22 20 21.1046 20 20V8L14 2Z" strokeWidth="2"/>
|
||||
<path d="M14 2V8H20" strokeWidth="2"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
return (
|
||||
<div className="asset-browser">
|
||||
<div className="asset-browser-header">
|
||||
<h3>{t.title}</h3>
|
||||
</div>
|
||||
<div className="asset-browser-empty">
|
||||
<p>{t.noProject}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderListView = () => (
|
||||
<div className="asset-browser-list">
|
||||
<div className="asset-browser-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="asset-search"
|
||||
placeholder={t.search}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="asset-browser-loading">
|
||||
<p>{t.loading}</p>
|
||||
</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="asset-browser-empty">
|
||||
<p>{t.empty}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="asset-list">
|
||||
{filteredAssets.map((asset, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`asset-item ${selectedPath === asset.path ? 'selected' : ''}`}
|
||||
onClick={() => handleAssetClick(asset)}
|
||||
onDoubleClick={() => handleAssetDoubleClick(asset)}
|
||||
>
|
||||
{getFileIcon(asset.extension)}
|
||||
<div className="asset-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="asset-type">
|
||||
{asset.extension || t.file}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="asset-browser">
|
||||
<div className="asset-browser-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<h3 style={{ margin: 0 }}>{t.title}</h3>
|
||||
<div className="view-mode-buttons">
|
||||
<button
|
||||
className={`view-mode-btn ${viewMode === 'tree-split' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('tree-split')}
|
||||
title={t.viewTreeSplit}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="7" height="18"/>
|
||||
<rect x="14" y="3" width="7" height="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`view-mode-btn ${viewMode === 'tree-only' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('tree-only')}
|
||||
title={t.viewTreeOnly}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="asset-browser-content">
|
||||
{viewMode === 'tree-only' ? (
|
||||
<div className="asset-browser-tree-only">
|
||||
<div className="asset-browser-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="asset-search"
|
||||
placeholder={t.search}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<FileTree
|
||||
rootPath={projectPath}
|
||||
onSelectFile={handleTreeSelect}
|
||||
selectedPath={selectedPath}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ResizablePanel
|
||||
direction="horizontal"
|
||||
defaultSize={200}
|
||||
minSize={150}
|
||||
maxSize={400}
|
||||
leftOrTop={
|
||||
<div className="asset-browser-tree">
|
||||
<FileTree
|
||||
rootPath={projectPath}
|
||||
onSelectFile={handleTreeSelect}
|
||||
selectedPath={selectedPath}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
rightOrBottom={renderListView()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
packages/editor-app/src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { X } from 'lucide-react';
|
||||
import '../styles/ConfirmDialog.css';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText: string;
|
||||
cancelText: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ title, message, confirmText, cancelText, onConfirm, onCancel }: ConfirmDialogProps) {
|
||||
return (
|
||||
<div className="confirm-dialog-overlay" onClick={onCancel}>
|
||||
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="confirm-dialog-header">
|
||||
<h2>{title}</h2>
|
||||
<button className="close-btn" onClick={onCancel}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="confirm-dialog-content">
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
<div className="confirm-dialog-footer">
|
||||
<button className="confirm-dialog-btn cancel" onClick={onCancel}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button className="confirm-dialog-btn confirm" onClick={onConfirm}>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
364
packages/editor-app/src/components/ConsolePanel.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { LogService, LogEntry } from '@esengine/editor-core';
|
||||
import { LogLevel } from '@esengine/ecs-framework';
|
||||
import { Trash2, AlertCircle, Info, AlertTriangle, XCircle, Bug, Search, Maximize2, ChevronRight, ChevronDown, Wifi } from 'lucide-react';
|
||||
import { JsonViewer } from './JsonViewer';
|
||||
import '../styles/ConsolePanel.css';
|
||||
|
||||
interface ConsolePanelProps {
|
||||
logService: LogService;
|
||||
}
|
||||
|
||||
export function ConsolePanel({ logService }: ConsolePanelProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [levelFilter, setLevelFilter] = useState<Set<LogLevel>>(new Set([
|
||||
LogLevel.Debug,
|
||||
LogLevel.Info,
|
||||
LogLevel.Warn,
|
||||
LogLevel.Error,
|
||||
LogLevel.Fatal
|
||||
]));
|
||||
const [showRemoteOnly, setShowRemoteOnly] = useState(false);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
|
||||
const [jsonViewerData, setJsonViewerData] = useState<any>(null);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLogs(logService.getLogs());
|
||||
|
||||
const unsubscribe = logService.subscribe((entry) => {
|
||||
setLogs(prev => [...prev, entry]);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [logService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
const handleClear = () => {
|
||||
logService.clear();
|
||||
setLogs([]);
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (logContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
|
||||
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
|
||||
setAutoScroll(isAtBottom);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLevelFilter = (level: LogLevel) => {
|
||||
const newFilter = new Set(levelFilter);
|
||||
if (newFilter.has(level)) {
|
||||
newFilter.delete(level);
|
||||
} else {
|
||||
newFilter.add(level);
|
||||
}
|
||||
setLevelFilter(newFilter);
|
||||
};
|
||||
|
||||
const filteredLogs = logs.filter(log => {
|
||||
if (!levelFilter.has(log.level)) return false;
|
||||
if (showRemoteOnly && log.source !== 'remote') return false;
|
||||
if (filter && !log.message.toLowerCase().includes(filter.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const getLevelIcon = (level: LogLevel) => {
|
||||
switch (level) {
|
||||
case LogLevel.Debug:
|
||||
return <Bug size={14} />;
|
||||
case LogLevel.Info:
|
||||
return <Info size={14} />;
|
||||
case LogLevel.Warn:
|
||||
return <AlertTriangle size={14} />;
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return <XCircle size={14} />;
|
||||
default:
|
||||
return <AlertCircle size={14} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getLevelClass = (level: LogLevel): string => {
|
||||
switch (level) {
|
||||
case LogLevel.Debug:
|
||||
return 'log-entry-debug';
|
||||
case LogLevel.Info:
|
||||
return 'log-entry-info';
|
||||
case LogLevel.Warn:
|
||||
return 'log-entry-warn';
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return 'log-entry-error';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (date: Date): string => {
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
const seconds = date.getSeconds().toString().padStart(2, '0');
|
||||
const ms = date.getMilliseconds().toString().padStart(3, '0');
|
||||
return `${hours}:${minutes}:${seconds}.${ms}`;
|
||||
};
|
||||
|
||||
const toggleLogExpand = (logId: number) => {
|
||||
const newExpanded = new Set(expandedLogs);
|
||||
if (newExpanded.has(logId)) {
|
||||
newExpanded.delete(logId);
|
||||
} else {
|
||||
newExpanded.add(logId);
|
||||
}
|
||||
setExpandedLogs(newExpanded);
|
||||
};
|
||||
|
||||
const extractJSON = (message: string): { prefix: string; json: string; suffix: string } | null => {
|
||||
const jsonStartChars = ['{', '['];
|
||||
let startIndex = -1;
|
||||
|
||||
for (const char of jsonStartChars) {
|
||||
const index = message.indexOf(char);
|
||||
if (index !== -1 && (startIndex === -1 || index < startIndex)) {
|
||||
startIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
if (startIndex === -1) return null;
|
||||
|
||||
for (let endIndex = message.length; endIndex > startIndex; endIndex--) {
|
||||
const possibleJson = message.substring(startIndex, endIndex);
|
||||
try {
|
||||
JSON.parse(possibleJson);
|
||||
return {
|
||||
prefix: message.substring(0, startIndex).trim(),
|
||||
json: possibleJson,
|
||||
suffix: message.substring(endIndex).trim()
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const tryParseJSON = (message: string): { isJSON: boolean; parsed?: any; jsonStr?: string } => {
|
||||
try {
|
||||
const parsed = JSON.parse(message);
|
||||
return { isJSON: true, parsed, jsonStr: message };
|
||||
} catch {
|
||||
const extracted = extractJSON(message);
|
||||
if (extracted) {
|
||||
try {
|
||||
const parsed = JSON.parse(extracted.json);
|
||||
return { isJSON: true, parsed, jsonStr: extracted.json };
|
||||
} catch {
|
||||
return { isJSON: false };
|
||||
}
|
||||
}
|
||||
return { isJSON: false };
|
||||
}
|
||||
};
|
||||
|
||||
const openJsonViewer = (jsonStr: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
setJsonViewerData(parsed);
|
||||
} catch {
|
||||
console.error('Failed to parse JSON:', jsonStr);
|
||||
}
|
||||
};
|
||||
|
||||
const formatMessage = (message: string, isExpanded: boolean): JSX.Element => {
|
||||
const MAX_PREVIEW_LENGTH = 200;
|
||||
const { isJSON, jsonStr } = tryParseJSON(message);
|
||||
const extracted = extractJSON(message);
|
||||
|
||||
const shouldTruncate = message.length > MAX_PREVIEW_LENGTH && !isExpanded;
|
||||
|
||||
return (
|
||||
<div className="log-message-container">
|
||||
<div className="log-message-text">
|
||||
{shouldTruncate ? (
|
||||
<>
|
||||
{extracted && extracted.prefix && <span>{extracted.prefix} </span>}
|
||||
<span className="log-message-preview">
|
||||
{message.substring(0, MAX_PREVIEW_LENGTH)}...
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{message}</span>
|
||||
)}
|
||||
</div>
|
||||
{isJSON && jsonStr && (
|
||||
<button
|
||||
className="log-open-json-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openJsonViewer(jsonStr);
|
||||
}}
|
||||
title="Open in JSON Viewer"
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const levelCounts = {
|
||||
[LogLevel.Debug]: logs.filter(l => l.level === LogLevel.Debug).length,
|
||||
[LogLevel.Info]: logs.filter(l => l.level === LogLevel.Info).length,
|
||||
[LogLevel.Warn]: logs.filter(l => l.level === LogLevel.Warn).length,
|
||||
[LogLevel.Error]: logs.filter(l => l.level === LogLevel.Error || l.level === LogLevel.Fatal).length
|
||||
};
|
||||
|
||||
const remoteLogCount = logs.filter(l => l.source === 'remote').length;
|
||||
|
||||
return (
|
||||
<div className="console-panel">
|
||||
<div className="console-toolbar">
|
||||
<div className="console-toolbar-left">
|
||||
<button
|
||||
className="console-btn"
|
||||
onClick={handleClear}
|
||||
title="Clear console"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<div className="console-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter logs..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="console-toolbar-right">
|
||||
<button
|
||||
className={`console-filter-btn ${showRemoteOnly ? 'active' : ''}`}
|
||||
onClick={() => setShowRemoteOnly(!showRemoteOnly)}
|
||||
title="Show Remote Logs Only"
|
||||
>
|
||||
<Wifi size={14} />
|
||||
{remoteLogCount > 0 && <span>{remoteLogCount}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`console-filter-btn ${levelFilter.has(LogLevel.Debug) ? 'active' : ''}`}
|
||||
onClick={() => toggleLevelFilter(LogLevel.Debug)}
|
||||
title="Debug"
|
||||
>
|
||||
<Bug size={14} />
|
||||
{levelCounts[LogLevel.Debug] > 0 && <span>{levelCounts[LogLevel.Debug]}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`console-filter-btn ${levelFilter.has(LogLevel.Info) ? 'active' : ''}`}
|
||||
onClick={() => toggleLevelFilter(LogLevel.Info)}
|
||||
title="Info"
|
||||
>
|
||||
<Info size={14} />
|
||||
{levelCounts[LogLevel.Info] > 0 && <span>{levelCounts[LogLevel.Info]}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`console-filter-btn ${levelFilter.has(LogLevel.Warn) ? 'active' : ''}`}
|
||||
onClick={() => toggleLevelFilter(LogLevel.Warn)}
|
||||
title="Warnings"
|
||||
>
|
||||
<AlertTriangle size={14} />
|
||||
{levelCounts[LogLevel.Warn] > 0 && <span>{levelCounts[LogLevel.Warn]}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`console-filter-btn ${levelFilter.has(LogLevel.Error) ? 'active' : ''}`}
|
||||
onClick={() => toggleLevelFilter(LogLevel.Error)}
|
||||
title="Errors"
|
||||
>
|
||||
<XCircle size={14} />
|
||||
{levelCounts[LogLevel.Error] > 0 && <span>{levelCounts[LogLevel.Error]}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="console-content"
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="console-empty">
|
||||
<AlertCircle size={32} />
|
||||
<p>No logs to display</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredLogs.map(log => {
|
||||
const isExpanded = expandedLogs.has(log.id);
|
||||
const shouldShowExpander = log.message.length > 200;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''} ${isExpanded ? 'log-entry-expanded' : ''}`}
|
||||
>
|
||||
{shouldShowExpander && (
|
||||
<div
|
||||
className="log-entry-expander"
|
||||
onClick={() => toggleLogExpand(log.id)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="log-entry-icon">
|
||||
{getLevelIcon(log.level)}
|
||||
</div>
|
||||
<div className="log-entry-time">
|
||||
{formatTime(log.timestamp)}
|
||||
</div>
|
||||
<div className={`log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
|
||||
[{log.source === 'remote' ? '🌐 Remote' : log.source}]
|
||||
</div>
|
||||
{log.clientId && (
|
||||
<div className="log-entry-client" title={`Client: ${log.clientId}`}>
|
||||
{log.clientId}
|
||||
</div>
|
||||
)}
|
||||
<div className="log-entry-message">
|
||||
{formatMessage(log.message, isExpanded)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{jsonViewerData && (
|
||||
<JsonViewer
|
||||
data={jsonViewerData}
|
||||
onClose={() => setJsonViewerData(null)}
|
||||
/>
|
||||
)}
|
||||
{!autoScroll && (
|
||||
<button
|
||||
className="console-scroll-to-bottom"
|
||||
onClick={() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
↓ Scroll to bottom
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
packages/editor-app/src/components/DockContainer.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { TabPanel, TabItem } from './TabPanel';
|
||||
import { ResizablePanel } from './ResizablePanel';
|
||||
import '../styles/DockContainer.css';
|
||||
|
||||
export type DockPosition = 'left' | 'right' | 'top' | 'bottom' | 'center';
|
||||
|
||||
export interface DockablePanel {
|
||||
id: string;
|
||||
title: string;
|
||||
content: ReactNode;
|
||||
position: DockPosition;
|
||||
closable?: boolean;
|
||||
}
|
||||
|
||||
interface DockContainerProps {
|
||||
panels: DockablePanel[];
|
||||
onPanelClose?: (panelId: string) => void;
|
||||
onPanelMove?: (panelId: string, newPosition: DockPosition) => void;
|
||||
}
|
||||
|
||||
export function DockContainer({ panels, onPanelClose }: DockContainerProps) {
|
||||
const groupedPanels = panels.reduce((acc, panel) => {
|
||||
if (!acc[panel.position]) {
|
||||
acc[panel.position] = [];
|
||||
}
|
||||
acc[panel.position].push(panel);
|
||||
return acc;
|
||||
}, {} as Record<DockPosition, DockablePanel[]>);
|
||||
|
||||
const renderPanelGroup = (position: DockPosition) => {
|
||||
const positionPanels = groupedPanels[position];
|
||||
if (!positionPanels || positionPanels.length === 0) return null;
|
||||
|
||||
const tabs: TabItem[] = positionPanels.map(panel => ({
|
||||
id: panel.id,
|
||||
title: panel.title,
|
||||
content: panel.content,
|
||||
closable: panel.closable
|
||||
}));
|
||||
|
||||
return (
|
||||
<TabPanel
|
||||
tabs={tabs}
|
||||
onTabClose={onPanelClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const leftPanel = groupedPanels['left'];
|
||||
const rightPanel = groupedPanels['right'];
|
||||
const topPanel = groupedPanels['top'];
|
||||
const bottomPanel = groupedPanels['bottom'];
|
||||
|
||||
const hasLeft = leftPanel && leftPanel.length > 0;
|
||||
const hasRight = rightPanel && rightPanel.length > 0;
|
||||
const hasTop = topPanel && topPanel.length > 0;
|
||||
const hasBottom = bottomPanel && bottomPanel.length > 0;
|
||||
|
||||
let content = (
|
||||
<div className="dock-center">
|
||||
{renderPanelGroup('center')}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (hasTop || hasBottom) {
|
||||
content = (
|
||||
<ResizablePanel
|
||||
direction="vertical"
|
||||
defaultSize={200}
|
||||
minSize={32}
|
||||
maxSize={600}
|
||||
storageKey="editor-panel-bottom-size"
|
||||
leftOrTop={content}
|
||||
rightOrBottom={
|
||||
<div className="dock-bottom">
|
||||
{renderPanelGroup('bottom')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTop) {
|
||||
content = (
|
||||
<ResizablePanel
|
||||
direction="vertical"
|
||||
defaultSize={200}
|
||||
minSize={32}
|
||||
maxSize={600}
|
||||
storageKey="editor-panel-top-size"
|
||||
leftOrTop={
|
||||
<div className="dock-top">
|
||||
{renderPanelGroup('top')}
|
||||
</div>
|
||||
}
|
||||
rightOrBottom={content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasLeft || hasRight) {
|
||||
if (hasLeft && hasRight) {
|
||||
content = (
|
||||
<ResizablePanel
|
||||
direction="horizontal"
|
||||
defaultSize={250}
|
||||
minSize={150}
|
||||
maxSize={400}
|
||||
storageKey="editor-panel-left-size"
|
||||
leftOrTop={
|
||||
<div className="dock-left">
|
||||
{renderPanelGroup('left')}
|
||||
</div>
|
||||
}
|
||||
rightOrBottom={
|
||||
<ResizablePanel
|
||||
direction="horizontal"
|
||||
side="right"
|
||||
defaultSize={280}
|
||||
minSize={200}
|
||||
maxSize={500}
|
||||
storageKey="editor-panel-right-size"
|
||||
leftOrTop={content}
|
||||
rightOrBottom={
|
||||
<div className="dock-right">
|
||||
{renderPanelGroup('right')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else if (hasLeft) {
|
||||
content = (
|
||||
<ResizablePanel
|
||||
direction="horizontal"
|
||||
defaultSize={250}
|
||||
minSize={150}
|
||||
maxSize={400}
|
||||
storageKey="editor-panel-left-size"
|
||||
leftOrTop={
|
||||
<div className="dock-left">
|
||||
{renderPanelGroup('left')}
|
||||
</div>
|
||||
}
|
||||
rightOrBottom={content}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<ResizablePanel
|
||||
direction="horizontal"
|
||||
side="right"
|
||||
defaultSize={280}
|
||||
minSize={200}
|
||||
maxSize={500}
|
||||
storageKey="editor-panel-right-size"
|
||||
leftOrTop={content}
|
||||
rightOrBottom={
|
||||
<div className="dock-right">
|
||||
{renderPanelGroup('right')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="dock-container">{content}</div>;
|
||||
}
|
||||
518
packages/editor-app/src/components/EntityInspector.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Entity, Core } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub, ComponentRegistry } 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(data.entity);
|
||||
setRemoteEntity(null);
|
||||
setRemoteEntityDetails(null);
|
||||
setComponentVersion(0);
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
window.addEventListener('profiler:entity-details', handleEntityDetails);
|
||||
|
||||
return () => {
|
||||
unsubSelect();
|
||||
unsubRemoteSelect();
|
||||
unsubComponentAdded();
|
||||
unsubComponentRemoved();
|
||||
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;
|
||||
messageHub.publish('component:property:changed', {
|
||||
entity: selectedEntity,
|
||||
component,
|
||||
propertyName,
|
||||
value
|
||||
});
|
||||
};
|
||||
|
||||
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
|
||||
component={component}
|
||||
onChange={(propertyName, value) => handlePropertyChange(component, propertyName, value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
packages/editor-app/src/components/ErrorDialog.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { X } from 'lucide-react';
|
||||
import '../styles/ErrorDialog.css';
|
||||
|
||||
interface ErrorDialogProps {
|
||||
title: string;
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ErrorDialog({ title, message, onClose }: ErrorDialogProps) {
|
||||
return (
|
||||
<div className="error-dialog-overlay" onClick={onClose}>
|
||||
<div className="error-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="error-dialog-header">
|
||||
<h2>{title}</h2>
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="error-dialog-content">
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
<div className="error-dialog-footer">
|
||||
<button className="error-dialog-btn" onClick={onClose}>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||