Compare commits
3 Commits
feat/docs-
...
feat/docs-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
157d0b8067 | ||
|
|
dd1ae97de7 | ||
|
|
63f006ab62 |
8
.github/codeql/codeql-config.yml
vendored
Normal file
8
.github/codeql/codeql-config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
name: "CodeQL Config"
|
||||
|
||||
# Paths to exclude from analysis
|
||||
paths-ignore:
|
||||
- thirdparty
|
||||
- "**/node_modules"
|
||||
- "**/dist"
|
||||
- "**/bin"
|
||||
1
.github/workflows/codeql.yml
vendored
1
.github/workflows/codeql.yml
vendored
@@ -31,6 +31,7 @@ jobs:
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
config-file: ./.github/codeql/codeql-config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
395
README.md
395
README.md
@@ -1,90 +1,61 @@
|
||||
# ECS Framework
|
||||
# ESEngine
|
||||
|
||||
[](https://github.com/esengine/ecs-framework/actions)
|
||||
[](https://codecov.io/gh/esengine/ecs-framework)
|
||||
[](https://badge.fury.io/js/%40esengine%2Fecs-framework)
|
||||
[](https://www.npmjs.com/package/@esengine/ecs-framework)
|
||||
[](https://bundlephobia.com/package/@esengine/ecs-framework)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](#contributors)
|
||||
[](https://github.com/esengine/ecs-framework/stargazers)
|
||||
[](https://deepwiki.com/esengine/ecs-framework)
|
||||
**English** | [中文](./README_CN.md)
|
||||
|
||||
<div align="center">
|
||||
**[Documentation](https://esengine.github.io/ecs-framework/) | [API Reference](https://esengine.github.io/ecs-framework/api/) | [Examples](./examples/)**
|
||||
|
||||
<p>一个高性能的 TypeScript ECS (Entity-Component-System) 框架,专为现代游戏开发而设计。</p>
|
||||
ESEngine is a cross-platform 2D game engine for creating games from a unified interface. It provides a comprehensive set of common tools so that developers can focus on making games without having to reinvent the wheel.
|
||||
|
||||
<p>A high-performance TypeScript ECS (Entity-Component-System) framework designed for modern game development.</p>
|
||||
Games can be exported to multiple platforms including Web browsers, WeChat Mini Games, and other mini-game platforms.
|
||||
|
||||
</div>
|
||||
## Free and Open Source
|
||||
|
||||
---
|
||||
ESEngine is completely free and open source under the MIT license. No strings attached, no royalties. Your games are yours.
|
||||
|
||||
## 📊 项目统计 / Project Stats
|
||||
## Features
|
||||
|
||||
<div align="center">
|
||||
- **Data-Driven Architecture**: Built on Entity-Component-System (ECS) pattern for flexible and performant game logic
|
||||
- **High-Performance Rendering**: Rust/WebAssembly 2D renderer with sprite batching and WebGL 2.0 backend
|
||||
- **Visual Editor**: Cross-platform desktop editor with scene management, asset browser, and visual tools
|
||||
- **Modular Design**: Use only what you need. Each feature is a separate module that can be included independently
|
||||
- **Multi-Platform**: Deploy to Web, WeChat Mini Games, and more from a single codebase
|
||||
|
||||
[](https://star-history.com/#esengine/ecs-framework&Date)
|
||||
## Getting the Engine
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://github.com/esengine/ecs-framework/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=esengine/ecs-framework" />
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
### 📈 下载趋势 / Download Trends
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://www.npmjs.com/package/@esengine/ecs-framework)
|
||||
|
||||
[](https://npmtrends.com/@esengine/ecs-framework)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 特性
|
||||
|
||||
- **高性能** - 针对大规模实体优化,支持SoA存储和批量处理
|
||||
- **多线程计算** - Worker系统支持真正的并行处理,充分利用多核CPU性能
|
||||
- **类型安全** - 完整的TypeScript支持,编译时类型检查
|
||||
- **现代架构** - 支持多World、多Scene的分层架构设计
|
||||
- **开发友好** - 内置调试工具和性能监控
|
||||
- **跨平台** - 支持Cocos Creator、Laya引擎和Web平台
|
||||
|
||||
## 安装
|
||||
### Using npm
|
||||
|
||||
```bash
|
||||
npm install @esengine/ecs-framework
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
### Building from Source
|
||||
|
||||
See [Building from Source](#building-from-source) for detailed instructions.
|
||||
|
||||
### Editor Download
|
||||
|
||||
Pre-built editor binaries are available on the [Releases](https://github.com/esengine/ecs-framework/releases) page for Windows and macOS.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { Core, Scene, Component, EntitySystem, ECSComponent, ECSSystem, Matcher, Time } from '@esengine/ecs-framework';
|
||||
import {
|
||||
Core, Scene, Entity, Component, EntitySystem,
|
||||
Matcher, Time, ECSComponent, ECSSystem
|
||||
} from '@esengine/ecs-framework';
|
||||
|
||||
// 定义组件
|
||||
@ECSComponent('Position')
|
||||
class Position extends Component {
|
||||
constructor(public x = 0, public y = 0) {
|
||||
super();
|
||||
}
|
||||
x = 0;
|
||||
y = 0;
|
||||
}
|
||||
|
||||
@ECSComponent('Velocity')
|
||||
class Velocity extends Component {
|
||||
constructor(public dx = 0, public dy = 0) {
|
||||
super();
|
||||
}
|
||||
dx = 0;
|
||||
dy = 0;
|
||||
}
|
||||
|
||||
// 创建系统
|
||||
@ECSSystem('Movement')
|
||||
class MovementSystem extends EntitySystem {
|
||||
constructor() {
|
||||
@@ -93,182 +64,182 @@ class MovementSystem extends EntitySystem {
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
for (const entity of entities) {
|
||||
const position = entity.getComponent(Position)!;
|
||||
const velocity = entity.getComponent(Velocity)!;
|
||||
|
||||
position.x += velocity.dx * Time.deltaTime;
|
||||
position.y += velocity.dy * Time.deltaTime;
|
||||
const pos = entity.getComponent(Position);
|
||||
const vel = entity.getComponent(Velocity);
|
||||
pos.x += vel.dx * Time.deltaTime;
|
||||
pos.y += vel.dy * Time.deltaTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建场景并启动
|
||||
class GameScene extends Scene {
|
||||
protected initialize(): void {
|
||||
this.addSystem(new MovementSystem());
|
||||
|
||||
const player = this.createEntity("Player");
|
||||
player.addComponent(new Position(100, 100));
|
||||
player.addComponent(new Velocity(50, 0));
|
||||
}
|
||||
}
|
||||
|
||||
// 启动游戏
|
||||
Core.create();
|
||||
Core.setScene(new GameScene());
|
||||
const scene = new Scene();
|
||||
scene.addSystem(new MovementSystem());
|
||||
|
||||
const player = scene.createEntity('Player');
|
||||
player.addComponent(new Position());
|
||||
player.addComponent(new Velocity());
|
||||
|
||||
Core.setScene(scene);
|
||||
|
||||
// Game loop
|
||||
let lastTime = 0;
|
||||
function gameLoop(currentTime: number) {
|
||||
const deltaTime = (currentTime - lastTime) / 1000;
|
||||
lastTime = currentTime;
|
||||
|
||||
// 游戏循环中更新
|
||||
function gameLoop(deltaTime: number) {
|
||||
Core.update(deltaTime);
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
requestAnimationFrame(gameLoop);
|
||||
```
|
||||
|
||||
## 核心特性
|
||||
## Modules
|
||||
|
||||
- **实体查询** - 使用 Matcher API 进行高效的实体过滤
|
||||
- **事件系统** - 类型安全的事件发布/订阅机制
|
||||
- **性能优化** - SoA 存储优化,支持大规模实体处理
|
||||
- **多线程支持** - Worker系统实现真正的并行计算,充分利用多核CPU
|
||||
- **多场景** - 支持 World/Scene 分层架构
|
||||
- **时间管理** - 内置定时器和时间控制系统
|
||||
ESEngine is organized into modular packages. Each feature has a runtime module and an optional editor extension.
|
||||
|
||||
## 🏗️ 架构设计 / Architecture
|
||||
### Core
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[Core 核心] --> B[World 世界]
|
||||
B --> C[Scene 场景]
|
||||
C --> D[EntityManager 实体管理器]
|
||||
C --> E[SystemManager 系统管理器]
|
||||
D --> F[Entity 实体]
|
||||
F --> G[Component 组件]
|
||||
E --> H[EntitySystem 实体系统]
|
||||
E --> I[WorkerSystem 工作线程系统]
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `@esengine/ecs-framework` | Core ECS framework with entity management, component system, and queries |
|
||||
| `@esengine/math` | Vector, matrix, and mathematical utilities |
|
||||
| `@esengine/engine` | Rust/WASM 2D renderer |
|
||||
| `@esengine/engine-core` | Engine module system and lifecycle management |
|
||||
|
||||
style A fill:#e1f5ff
|
||||
style B fill:#fff3e0
|
||||
style C fill:#f3e5f5
|
||||
style D fill:#e8f5e9
|
||||
style E fill:#fff9c4
|
||||
style F fill:#ffebee
|
||||
style G fill:#e0f2f1
|
||||
style H fill:#fce4ec
|
||||
style I fill:#f1f8e9
|
||||
### Runtime Modules
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `@esengine/sprite` | 2D sprite rendering and animation |
|
||||
| `@esengine/tilemap` | Tile-based map rendering with animation support |
|
||||
| `@esengine/physics-rapier2d` | 2D physics simulation powered by Rapier |
|
||||
| `@esengine/behavior-tree` | Behavior tree AI system |
|
||||
| `@esengine/blueprint` | Visual scripting runtime |
|
||||
| `@esengine/camera` | Camera control and management |
|
||||
| `@esengine/audio` | Audio playback |
|
||||
| `@esengine/ui` | UI components |
|
||||
| `@esengine/material-system` | Material and shader system |
|
||||
| `@esengine/asset-system` | Asset loading and management |
|
||||
|
||||
### Editor Extensions
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `@esengine/sprite-editor` | Sprite inspector and tools |
|
||||
| `@esengine/tilemap-editor` | Visual tilemap editor with brush tools |
|
||||
| `@esengine/physics-rapier2d-editor` | Physics collider visualization and editing |
|
||||
| `@esengine/behavior-tree-editor` | Visual behavior tree editor |
|
||||
| `@esengine/blueprint-editor` | Visual scripting editor |
|
||||
| `@esengine/material-editor` | Material and shader editor |
|
||||
| `@esengine/shader-editor` | Shader code editor |
|
||||
|
||||
### Platform
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `@esengine/platform-common` | Platform abstraction interfaces |
|
||||
| `@esengine/platform-web` | Web browser runtime |
|
||||
| `@esengine/platform-wechat` | WeChat Mini Game runtime |
|
||||
|
||||
## Editor
|
||||
|
||||
ESEngine Editor is a cross-platform desktop application built with Tauri and React.
|
||||
|
||||
### Features
|
||||
|
||||
- Scene hierarchy and entity management
|
||||
- Component inspector with custom editors
|
||||
- Asset browser with drag-and-drop support
|
||||
- Tilemap editor with paint, fill, and selection tools
|
||||
- Behavior tree visual editor
|
||||
- Blueprint visual scripting
|
||||
- Material and shader editing
|
||||
- Built-in performance profiler
|
||||
- Localization support (English, Chinese)
|
||||
|
||||
### Screenshot
|
||||
|
||||

|
||||
|
||||
## Supported Platforms
|
||||
|
||||
| Platform | Runtime | Editor |
|
||||
|----------|---------|--------|
|
||||
| Web Browser | Yes | - |
|
||||
| Windows | - | Yes |
|
||||
| macOS | - | Yes |
|
||||
| WeChat Mini Game | In Progress | - |
|
||||
| Playable Ads | Planned | - |
|
||||
| Android | Planned | - |
|
||||
| iOS | Planned | - |
|
||||
| Windows Native | Planned | - |
|
||||
| Other Platforms | Planned | - |
|
||||
|
||||
## Building from Source
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18 or later
|
||||
- pnpm 10 or later
|
||||
- Rust toolchain (for WASM renderer)
|
||||
- wasm-pack
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/esengine/ecs-framework.git
|
||||
cd ecs-framework
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Build all packages
|
||||
pnpm build
|
||||
|
||||
# Build WASM renderer (optional)
|
||||
pnpm build:wasm
|
||||
```
|
||||
|
||||
## 平台支持
|
||||
### Running the Editor
|
||||
|
||||
支持主流游戏引擎和 Web 平台:
|
||||
```bash
|
||||
cd packages/editor-app
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
- **Cocos Creator**
|
||||
- **Laya 引擎**
|
||||
- **原生 Web** - 浏览器环境直接运行
|
||||
- **小游戏平台** - 微信、支付宝等小游戏
|
||||
### Project Structure
|
||||
|
||||
## ECS Framework Editor
|
||||
```
|
||||
ecs-framework/
|
||||
├── packages/ Engine packages (runtime, editor, platform)
|
||||
├── docs/ Documentation source
|
||||
├── examples/ Example projects
|
||||
├── scripts/ Build utilities
|
||||
└── thirdparty/ Third-party dependencies
|
||||
```
|
||||
|
||||
跨平台桌面编辑器,提供可视化开发和调试工具。
|
||||
## Documentation
|
||||
|
||||
### 主要功能
|
||||
- [Getting Started](https://esengine.github.io/ecs-framework/guide/getting-started.html)
|
||||
- [Architecture Guide](https://esengine.github.io/ecs-framework/guide/)
|
||||
- [API Reference](https://esengine.github.io/ecs-framework/api/)
|
||||
|
||||
- **场景管理** - 可视化场景层级和实体管理
|
||||
- **组件检视** - 实时查看和编辑实体组件
|
||||
- **性能分析** - 内置 Profiler 监控系统性能
|
||||
- **插件系统** - 可扩展的插件架构
|
||||
- **远程调试** - 连接运行中的游戏进行实时调试
|
||||
- **自动更新** - 支持热更新,自动获取最新版本
|
||||
## Community
|
||||
|
||||
### 下载
|
||||
- [GitHub Issues](https://github.com/esengine/ecs-framework/issues) - Bug reports and feature requests
|
||||
- [GitHub Discussions](https://github.com/esengine/ecs-framework/discussions) - Questions and ideas
|
||||
|
||||
[](https://github.com/esengine/ecs-framework/releases/latest)
|
||||
## Contributing
|
||||
|
||||
支持 Windows、macOS (Intel & Apple Silicon)
|
||||
Contributions are welcome. Please read the contributing guidelines before submitting a pull request.
|
||||
|
||||
### 截图
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make changes with tests
|
||||
4. Submit a pull request
|
||||
|
||||
<img src="screenshots/main_screetshot.png" alt="ECS Framework Editor" width="800">
|
||||
## License
|
||||
|
||||
<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>
|
||||
|
||||
## 示例项目
|
||||
|
||||
- [Worker系统演示](https://esengine.github.io/ecs-framework/demos/worker-system/) - 多线程物理系统演示,展示高性能并行计算
|
||||
- [割草机演示](https://github.com/esengine/lawn-mower-demo) - 完整的游戏示例
|
||||
|
||||
## 文档
|
||||
|
||||
- [📚 AI智能文档](https://deepwiki.com/esengine/ecs-framework) - AI助手随时解答你的问题
|
||||
- [快速入门](https://esengine.github.io/ecs-framework/guide/getting-started.html) - 详细教程和平台集成
|
||||
- [完整指南](https://esengine.github.io/ecs-framework/guide/) - ECS 概念和使用指南
|
||||
- [API 参考](https://esengine.github.io/ecs-framework/api/) - 完整 API 文档
|
||||
|
||||
## 生态系统
|
||||
|
||||
- [路径寻找](https://github.com/esengine/ecs-astar) - A*、BFS、Dijkstra 算法
|
||||
- [AI 系统](https://github.com/esengine/BehaviourTree-ai) - 行为树、效用 AI
|
||||
|
||||
## 💪 支持项目 / Support the Project
|
||||
|
||||
如果这个项目对你有帮助,请考虑:
|
||||
|
||||
If this project helps you, please consider:
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/sponsors/esengine)
|
||||
[](https://github.com/esengine/ecs-framework)
|
||||
|
||||
</div>
|
||||
|
||||
- ⭐ 给项目点个 Star
|
||||
- 🐛 报告 Bug 或提出新功能
|
||||
- 📝 改进文档
|
||||
- 💖 成为赞助者
|
||||
|
||||
## 社区与支持
|
||||
|
||||
- [问题反馈](https://github.com/esengine/ecs-framework/issues) - Bug 报告和功能建议
|
||||
- [讨论区](https://github.com/esengine/ecs-framework/discussions) - 提问、分享想法
|
||||
- [QQ 交流群](https://jq.qq.com/?_wv=1027&k=29w1Nud6) - ecs游戏框架交流
|
||||
|
||||
## 贡献者 / Contributors
|
||||
|
||||
感谢所有为这个项目做出贡献的人!
|
||||
|
||||
Thanks goes to these wonderful people:
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- markdownlint-disable -->
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/esengine"><img src="https://avatars.githubusercontent.com/esengine?s=100" width="100px;" alt="esengine"/><br /><sub><b>esengine</b></sub></a><br /><a href="#maintenance-esengine" title="Maintenance">🚧</a> <a href="https://github.com/esengine/ecs-framework/commits?author=esengine" title="Code">💻</a> <a href="#design-esengine" title="Design">🎨</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/foxling"><img src="https://avatars.githubusercontent.com/foxling?s=100" width="100px;" alt="LING YE"/><br /><sub><b>LING YE</b></sub></a><br /><a href="https://github.com/esengine/ecs-framework/commits?author=foxling" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/MirageTank"><img src="https://avatars.githubusercontent.com/MirageTank?s=100" width="100px;" alt="MirageTank"/><br /><sub><b>MirageTank</b></sub></a><br /><a href="https://github.com/esengine/ecs-framework/commits?author=MirageTank" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- markdownlint-restore -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
本项目遵循 [all-contributors](https://github.com/all-contributors/all-contributors) 规范。欢迎任何形式的贡献!
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](LICENSE) © 2025 ECS Framework
|
||||
ESEngine is licensed under the [MIT License](LICENSE).
|
||||
|
||||
246
README_CN.md
Normal file
246
README_CN.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# ESEngine
|
||||
|
||||
[English](./README.md) | **中文**
|
||||
|
||||
**[文档](https://esengine.github.io/ecs-framework/) | [API 参考](https://esengine.github.io/ecs-framework/api/) | [示例](./examples/)**
|
||||
|
||||
ESEngine 是一个跨平台 2D 游戏引擎,提供统一的开发界面。它包含完整的常用工具集,让开发者专注于游戏创作本身。
|
||||
|
||||
游戏可以导出到多个平台,包括 Web 浏览器、微信小游戏等小游戏平台。
|
||||
|
||||
## 免费开源
|
||||
|
||||
ESEngine 基于 MIT 协议完全免费开源。无附加条件,无版税。你的游戏完全属于你。
|
||||
|
||||
## 特性
|
||||
|
||||
- **数据驱动架构**:基于 ECS(实体-组件-系统)模式构建,提供灵活高效的游戏逻辑
|
||||
- **高性能渲染**:Rust/WebAssembly 2D 渲染器,支持精灵批处理和 WebGL 2.0
|
||||
- **可视化编辑器**:跨平台桌面编辑器,包含场景管理、资源浏览器和可视化工具
|
||||
- **模块化设计**:按需使用,每个功能都是独立模块,可单独引入
|
||||
- **多平台支持**:一套代码部署到 Web、微信小游戏等多个平台
|
||||
|
||||
## 获取引擎
|
||||
|
||||
### 通过 npm 安装
|
||||
|
||||
```bash
|
||||
npm install @esengine/ecs-framework
|
||||
```
|
||||
|
||||
### 从源码构建
|
||||
|
||||
详见 [从源码构建](#从源码构建) 章节。
|
||||
|
||||
### 编辑器下载
|
||||
|
||||
预编译的编辑器可在 [Releases](https://github.com/esengine/ecs-framework/releases) 页面下载,支持 Windows 和 macOS。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Core, Scene, Entity, Component, EntitySystem,
|
||||
Matcher, Time, ECSComponent, ECSSystem
|
||||
} from '@esengine/ecs-framework';
|
||||
|
||||
@ECSComponent('Position')
|
||||
class Position extends Component {
|
||||
x = 0;
|
||||
y = 0;
|
||||
}
|
||||
|
||||
@ECSComponent('Velocity')
|
||||
class Velocity extends Component {
|
||||
dx = 0;
|
||||
dy = 0;
|
||||
}
|
||||
|
||||
@ECSSystem('Movement')
|
||||
class MovementSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.all(Position, Velocity));
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
for (const entity of entities) {
|
||||
const pos = entity.getComponent(Position);
|
||||
const vel = entity.getComponent(Velocity);
|
||||
pos.x += vel.dx * Time.deltaTime;
|
||||
pos.y += vel.dy * Time.deltaTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Core.create();
|
||||
const scene = new Scene();
|
||||
scene.addSystem(new MovementSystem());
|
||||
|
||||
const player = scene.createEntity('Player');
|
||||
player.addComponent(new Position());
|
||||
player.addComponent(new Velocity());
|
||||
|
||||
Core.setScene(scene);
|
||||
|
||||
// 游戏循环
|
||||
let lastTime = 0;
|
||||
function gameLoop(currentTime: number) {
|
||||
const deltaTime = (currentTime - lastTime) / 1000;
|
||||
lastTime = currentTime;
|
||||
|
||||
Core.update(deltaTime);
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
requestAnimationFrame(gameLoop);
|
||||
```
|
||||
|
||||
## 模块
|
||||
|
||||
ESEngine 采用模块化组织。每个功能都有运行时模块和可选的编辑器扩展。
|
||||
|
||||
### 核心
|
||||
|
||||
| 包名 | 描述 |
|
||||
|------|------|
|
||||
| `@esengine/ecs-framework` | ECS 框架核心,包含实体管理、组件系统和查询 |
|
||||
| `@esengine/math` | 向量、矩阵和数学工具 |
|
||||
| `@esengine/engine` | Rust/WASM 2D 渲染器 |
|
||||
| `@esengine/engine-core` | 引擎模块系统和生命周期管理 |
|
||||
|
||||
### 运行时模块
|
||||
|
||||
| 包名 | 描述 |
|
||||
|------|------|
|
||||
| `@esengine/sprite` | 2D 精灵渲染和动画 |
|
||||
| `@esengine/tilemap` | Tilemap 渲染,支持动画 |
|
||||
| `@esengine/physics-rapier2d` | 基于 Rapier 的 2D 物理模拟 |
|
||||
| `@esengine/behavior-tree` | 行为树 AI 系统 |
|
||||
| `@esengine/blueprint` | 可视化脚本运行时 |
|
||||
| `@esengine/camera` | 相机控制和管理 |
|
||||
| `@esengine/audio` | 音频播放 |
|
||||
| `@esengine/ui` | UI 组件 |
|
||||
| `@esengine/material-system` | 材质和着色器系统 |
|
||||
| `@esengine/asset-system` | 资源加载和管理 |
|
||||
|
||||
### 编辑器扩展
|
||||
|
||||
| 包名 | 描述 |
|
||||
|------|------|
|
||||
| `@esengine/sprite-editor` | 精灵检视器和工具 |
|
||||
| `@esengine/tilemap-editor` | 可视化 Tilemap 编辑器,支持笔刷工具 |
|
||||
| `@esengine/physics-rapier2d-editor` | 物理碰撞体可视化和编辑 |
|
||||
| `@esengine/behavior-tree-editor` | 可视化行为树编辑器 |
|
||||
| `@esengine/blueprint-editor` | 可视化脚本编辑器 |
|
||||
| `@esengine/material-editor` | 材质和着色器编辑器 |
|
||||
| `@esengine/shader-editor` | 着色器代码编辑器 |
|
||||
|
||||
### 平台
|
||||
|
||||
| 包名 | 描述 |
|
||||
|------|------|
|
||||
| `@esengine/platform-common` | 平台抽象接口 |
|
||||
| `@esengine/platform-web` | Web 浏览器运行时 |
|
||||
| `@esengine/platform-wechat` | 微信小游戏运行时 |
|
||||
|
||||
## 编辑器
|
||||
|
||||
ESEngine 编辑器是基于 Tauri 和 React 构建的跨平台桌面应用。
|
||||
|
||||
### 功能
|
||||
|
||||
- 场景层级和实体管理
|
||||
- 组件检视器,支持自定义编辑器
|
||||
- 资源浏览器,支持拖放
|
||||
- Tilemap 编辑器,支持绘制、填充、选择工具
|
||||
- 行为树可视化编辑器
|
||||
- 蓝图可视化脚本
|
||||
- 材质和着色器编辑
|
||||
- 内置性能分析器
|
||||
- 多语言支持(英文、中文)
|
||||
|
||||
### 截图
|
||||
|
||||

|
||||
|
||||
## 支持的平台
|
||||
|
||||
| 平台 | 运行时 | 编辑器 |
|
||||
|------|--------|--------|
|
||||
| Web 浏览器 | 支持 | - |
|
||||
| Windows | - | 支持 |
|
||||
| macOS | - | 支持 |
|
||||
| 微信小游戏 | 开发中 | - |
|
||||
| Playable 可玩广告 | 计划中 | - |
|
||||
| Android | 计划中 | - |
|
||||
| iOS | 计划中 | - |
|
||||
| Windows 原生 | 计划中 | - |
|
||||
| 其他平台 | 计划中 | - |
|
||||
|
||||
## 从源码构建
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Node.js 18 或更高版本
|
||||
- pnpm 10 或更高版本
|
||||
- Rust 工具链(用于 WASM 渲染器)
|
||||
- wasm-pack
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/esengine/ecs-framework.git
|
||||
cd ecs-framework
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 构建所有包
|
||||
pnpm build
|
||||
|
||||
# 构建 WASM 渲染器(可选)
|
||||
pnpm build:wasm
|
||||
```
|
||||
|
||||
### 运行编辑器
|
||||
|
||||
```bash
|
||||
cd packages/editor-app
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
ecs-framework/
|
||||
├── packages/ 引擎包(运行时、编辑器、平台)
|
||||
├── docs/ 文档源码
|
||||
├── examples/ 示例项目
|
||||
├── scripts/ 构建工具
|
||||
└── thirdparty/ 第三方依赖
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
- [快速入门](https://esengine.github.io/ecs-framework/guide/getting-started.html)
|
||||
- [架构指南](https://esengine.github.io/ecs-framework/guide/)
|
||||
- [API 参考](https://esengine.github.io/ecs-framework/api/)
|
||||
|
||||
## 社区
|
||||
|
||||
- [GitHub Issues](https://github.com/esengine/ecs-framework/issues) - Bug 反馈和功能建议
|
||||
- [GitHub Discussions](https://github.com/esengine/ecs-framework/discussions) - 问题和想法
|
||||
- [QQ 交流群](https://jq.qq.com/?_wv=1027&k=29w1Nud6) - 中文社区
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎贡献代码。提交 PR 前请阅读贡献指南。
|
||||
|
||||
1. Fork 仓库
|
||||
2. 创建功能分支
|
||||
3. 修改代码并测试
|
||||
4. 提交 PR
|
||||
|
||||
## 许可证
|
||||
|
||||
ESEngine 基于 [MIT 协议](LICENSE) 开源。
|
||||
@@ -9,6 +9,184 @@ const corePackageJson = JSON.parse(
|
||||
readFileSync(join(__dirname, '../../packages/core/package.json'), 'utf-8')
|
||||
)
|
||||
|
||||
// Import i18n messages
|
||||
import en from './i18n/en.json' with { type: 'json' }
|
||||
import zh from './i18n/zh.json' with { type: 'json' }
|
||||
|
||||
// 创建侧边栏配置 | Create sidebar config
|
||||
// prefix: 路径前缀,如 '' 或 '/en' | Path prefix like '' or '/en'
|
||||
function createSidebar(t, prefix = '') {
|
||||
return {
|
||||
[`${prefix}/guide/`]: [
|
||||
{
|
||||
text: t.sidebar.gettingStarted,
|
||||
items: [
|
||||
{ text: t.sidebar.quickStart, link: `${prefix}/guide/getting-started` },
|
||||
{ text: t.sidebar.guideOverview, link: `${prefix}/guide/` }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: t.sidebar.coreConcepts,
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: t.sidebar.entity, link: `${prefix}/guide/entity` },
|
||||
{ text: t.sidebar.hierarchy, link: `${prefix}/guide/hierarchy` },
|
||||
{ text: t.sidebar.component, link: `${prefix}/guide/component` },
|
||||
{ text: t.sidebar.entityQuery, link: `${prefix}/guide/entity-query` },
|
||||
{
|
||||
text: t.sidebar.system,
|
||||
link: `${prefix}/guide/system`,
|
||||
items: [
|
||||
{ text: t.sidebar.workerSystem, link: `${prefix}/guide/worker-system` }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: t.sidebar.scene,
|
||||
link: `${prefix}/guide/scene`,
|
||||
items: [
|
||||
{ text: t.sidebar.sceneManager, link: `${prefix}/guide/scene-manager` },
|
||||
{ text: t.sidebar.worldManager, link: `${prefix}/guide/world-manager` }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: t.sidebar.behaviorTree,
|
||||
link: `${prefix}/guide/behavior-tree/`,
|
||||
items: [
|
||||
{ text: t.sidebar.btGettingStarted, link: `${prefix}/guide/behavior-tree/getting-started` },
|
||||
{ text: t.sidebar.btCoreConcepts, link: `${prefix}/guide/behavior-tree/core-concepts` },
|
||||
{ text: t.sidebar.btEditorGuide, link: `${prefix}/guide/behavior-tree/editor-guide` },
|
||||
{ text: t.sidebar.btEditorWorkflow, link: `${prefix}/guide/behavior-tree/editor-workflow` },
|
||||
{ text: t.sidebar.btCustomActions, link: `${prefix}/guide/behavior-tree/custom-actions` },
|
||||
{ text: t.sidebar.btCocosIntegration, link: `${prefix}/guide/behavior-tree/cocos-integration` },
|
||||
{ text: t.sidebar.btLayaIntegration, link: `${prefix}/guide/behavior-tree/laya-integration` },
|
||||
{ text: t.sidebar.btAdvancedUsage, link: `${prefix}/guide/behavior-tree/advanced-usage` },
|
||||
{ text: t.sidebar.btBestPractices, link: `${prefix}/guide/behavior-tree/best-practices` }
|
||||
]
|
||||
},
|
||||
{ text: t.sidebar.serialization, link: `${prefix}/guide/serialization` },
|
||||
{ text: t.sidebar.eventSystem, link: `${prefix}/guide/event-system` },
|
||||
{ text: t.sidebar.timeAndTimers, link: `${prefix}/guide/time-and-timers` },
|
||||
{ text: t.sidebar.logging, link: `${prefix}/guide/logging` }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: t.sidebar.advancedFeatures,
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: t.sidebar.serviceContainer, link: `${prefix}/guide/service-container` },
|
||||
{ text: t.sidebar.pluginSystem, link: `${prefix}/guide/plugin-system` }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: t.sidebar.platformAdapters,
|
||||
link: `${prefix}/guide/platform-adapter`,
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: t.sidebar.browserAdapter, link: `${prefix}/guide/platform-adapter/browser` },
|
||||
{ text: t.sidebar.wechatAdapter, link: `${prefix}/guide/platform-adapter/wechat-minigame` },
|
||||
{ text: t.sidebar.nodejsAdapter, link: `${prefix}/guide/platform-adapter/nodejs` }
|
||||
]
|
||||
}
|
||||
],
|
||||
[`${prefix}/examples/`]: [
|
||||
{
|
||||
text: t.sidebar.examples,
|
||||
items: [
|
||||
{ text: t.sidebar.examplesOverview, link: `${prefix}/examples/` },
|
||||
{ text: t.nav.workerDemo, link: `${prefix}/examples/worker-system-demo` }
|
||||
]
|
||||
}
|
||||
],
|
||||
[`${prefix}/api/`]: [
|
||||
{
|
||||
text: t.sidebar.apiReference,
|
||||
items: [
|
||||
{ text: t.sidebar.overview, link: `${prefix}/api/README` },
|
||||
{
|
||||
text: t.sidebar.coreClasses,
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Core', link: `${prefix}/api/classes/Core` },
|
||||
{ text: 'Scene', link: `${prefix}/api/classes/Scene` },
|
||||
{ text: 'World', link: `${prefix}/api/classes/World` },
|
||||
{ text: 'Entity', link: `${prefix}/api/classes/Entity` },
|
||||
{ text: 'Component', link: `${prefix}/api/classes/Component` },
|
||||
{ text: 'EntitySystem', link: `${prefix}/api/classes/EntitySystem` }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: t.sidebar.systemClasses,
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: 'PassiveSystem', link: `${prefix}/api/classes/PassiveSystem` },
|
||||
{ text: 'ProcessingSystem', link: `${prefix}/api/classes/ProcessingSystem` },
|
||||
{ text: 'IntervalSystem', link: `${prefix}/api/classes/IntervalSystem` }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: t.sidebar.utilities,
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: 'Matcher', link: `${prefix}/api/classes/Matcher` },
|
||||
{ text: 'Time', link: `${prefix}/api/classes/Time` },
|
||||
{ text: 'PerformanceMonitor', link: `${prefix}/api/classes/PerformanceMonitor` },
|
||||
{ text: 'DebugManager', link: `${prefix}/api/classes/DebugManager` }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: t.sidebar.interfaces,
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: 'IScene', link: `${prefix}/api/interfaces/IScene` },
|
||||
{ text: 'IComponent', link: `${prefix}/api/interfaces/IComponent` },
|
||||
{ text: 'ISystemBase', link: `${prefix}/api/interfaces/ISystemBase` },
|
||||
{ text: 'ICoreConfig', link: `${prefix}/api/interfaces/ICoreConfig` }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: t.sidebar.decorators,
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: '@ECSComponent', link: `${prefix}/api/functions/ECSComponent` },
|
||||
{ text: '@ECSSystem', link: `${prefix}/api/functions/ECSSystem` }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: t.sidebar.enums,
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: 'ECSEventType', link: `${prefix}/api/enumerations/ECSEventType` },
|
||||
{ text: 'LogLevel', link: `${prefix}/api/enumerations/LogLevel` }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 创建导航配置 | Create nav config
|
||||
// prefix: 路径前缀,如 '' 或 '/en' | Path prefix like '' or '/en'
|
||||
function createNav(t, prefix = '') {
|
||||
return [
|
||||
{ text: t.nav.home, link: `${prefix}/` },
|
||||
{ text: t.nav.quickStart, link: `${prefix}/guide/getting-started` },
|
||||
{ text: t.nav.guide, link: `${prefix}/guide/` },
|
||||
{ text: t.nav.api, link: `${prefix}/api/README` },
|
||||
{
|
||||
text: t.nav.examples,
|
||||
items: [
|
||||
{ text: t.nav.workerDemo, link: `${prefix}/examples/worker-system-demo` },
|
||||
{ text: t.nav.lawnMowerDemo, link: 'https://github.com/esengine/lawn-mower-demo' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: `v${corePackageJson.version}`,
|
||||
link: 'https://github.com/esengine/ecs-framework/releases'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
plugins: [
|
||||
@@ -29,180 +207,49 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
title: 'ESEngine',
|
||||
description: '高性能 TypeScript ECS 框架 - 为游戏开发而生',
|
||||
lang: 'zh-CN',
|
||||
|
||||
appearance: 'force-dark',
|
||||
|
||||
locales: {
|
||||
root: {
|
||||
label: '简体中文',
|
||||
lang: 'zh-CN',
|
||||
description: '高性能 TypeScript ECS 框架 - 为游戏开发而生',
|
||||
themeConfig: {
|
||||
nav: createNav(zh, ''),
|
||||
sidebar: createSidebar(zh, ''),
|
||||
editLink: {
|
||||
pattern: 'https://github.com/esengine/ecs-framework/edit/master/docs/:path',
|
||||
text: zh.common.editOnGithub
|
||||
},
|
||||
outline: {
|
||||
level: [2, 3],
|
||||
label: zh.common.onThisPage
|
||||
}
|
||||
}
|
||||
},
|
||||
en: {
|
||||
label: 'English',
|
||||
lang: 'en',
|
||||
link: '/en/',
|
||||
description: 'High-performance TypeScript ECS Framework for Game Development',
|
||||
themeConfig: {
|
||||
nav: createNav(en, '/en'),
|
||||
sidebar: createSidebar(en, '/en'),
|
||||
editLink: {
|
||||
pattern: 'https://github.com/esengine/ecs-framework/edit/master/docs/:path',
|
||||
text: en.common.editOnGithub
|
||||
},
|
||||
outline: {
|
||||
level: [2, 3],
|
||||
label: en.common.onThisPage
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
themeConfig: {
|
||||
siteTitle: 'ESEngine',
|
||||
|
||||
nav: [
|
||||
{ text: '首页', link: '/' },
|
||||
{ text: '快速开始', link: '/guide/getting-started' },
|
||||
{ text: '指南', link: '/guide/' },
|
||||
{ text: 'API', link: '/api/README' },
|
||||
{
|
||||
text: '示例',
|
||||
items: [
|
||||
{ text: 'Worker系统演示', link: '/examples/worker-system-demo' },
|
||||
{ text: '割草机演示', link: 'https://github.com/esengine/lawn-mower-demo' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: `v${corePackageJson.version}`,
|
||||
link: 'https://github.com/esengine/ecs-framework/releases'
|
||||
}
|
||||
],
|
||||
|
||||
sidebar: {
|
||||
'/guide/': [
|
||||
{
|
||||
text: '开始使用',
|
||||
items: [
|
||||
{ text: '快速开始', link: '/guide/getting-started' },
|
||||
{ text: '指南概览', link: '/guide/' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '核心概念',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: '实体类 (Entity)', link: '/guide/entity' },
|
||||
{ text: '层级系统 (Hierarchy)', link: '/guide/hierarchy' },
|
||||
{ text: '组件系统 (Component)', link: '/guide/component' },
|
||||
{ text: '实体查询系统', link: '/guide/entity-query' },
|
||||
{
|
||||
text: '系统架构 (System)',
|
||||
link: '/guide/system',
|
||||
items: [
|
||||
{ text: 'Worker系统 (多线程)', link: '/guide/worker-system' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '场景管理 (Scene)',
|
||||
link: '/guide/scene',
|
||||
items: [
|
||||
{ text: 'SceneManager', link: '/guide/scene-manager' },
|
||||
{ text: 'WorldManager', link: '/guide/world-manager' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '行为树系统 (Behavior Tree)',
|
||||
link: '/guide/behavior-tree/',
|
||||
items: [
|
||||
{ text: '快速开始', link: '/guide/behavior-tree/getting-started' },
|
||||
{ text: '核心概念', link: '/guide/behavior-tree/core-concepts' },
|
||||
{ text: '编辑器指南', link: '/guide/behavior-tree/editor-guide' },
|
||||
{ text: '编辑器工作流', link: '/guide/behavior-tree/editor-workflow' },
|
||||
{ text: '自定义动作组件', link: '/guide/behavior-tree/custom-actions' },
|
||||
{ text: 'Cocos Creator集成', link: '/guide/behavior-tree/cocos-integration' },
|
||||
{ text: 'Laya引擎集成', link: '/guide/behavior-tree/laya-integration' },
|
||||
{ text: '高级用法', link: '/guide/behavior-tree/advanced-usage' },
|
||||
{ text: '最佳实践', link: '/guide/behavior-tree/best-practices' }
|
||||
]
|
||||
},
|
||||
{ text: '序列化系统 (Serialization)', link: '/guide/serialization' },
|
||||
{ text: '事件系统 (Event)', link: '/guide/event-system' },
|
||||
{ text: '时间和定时器 (Time)', link: '/guide/time-and-timers' },
|
||||
{ text: '日志系统 (Logger)', link: '/guide/logging' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '高级特性',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: '服务容器 (Service Container)', link: '/guide/service-container' },
|
||||
{ text: '插件系统 (Plugin System)', link: '/guide/plugin-system' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '平台适配器',
|
||||
link: '/guide/platform-adapter',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: '浏览器适配器', link: '/guide/platform-adapter/browser' },
|
||||
{ text: '微信小游戏适配器', link: '/guide/platform-adapter/wechat-minigame' },
|
||||
{ text: 'Node.js适配器', link: '/guide/platform-adapter/nodejs' }
|
||||
]
|
||||
}
|
||||
],
|
||||
'/examples/': [
|
||||
{
|
||||
text: '示例',
|
||||
items: [
|
||||
{ text: '示例概览', link: '/examples/' },
|
||||
{ text: 'Worker系统演示', link: '/examples/worker-system-demo' }
|
||||
]
|
||||
}
|
||||
],
|
||||
'/api/': [
|
||||
{
|
||||
text: 'API 参考',
|
||||
items: [
|
||||
{ text: '概述', link: '/api/README' },
|
||||
{
|
||||
text: '核心类',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Core', link: '/api/classes/Core' },
|
||||
{ text: 'Scene', link: '/api/classes/Scene' },
|
||||
{ text: 'World', link: '/api/classes/World' },
|
||||
{ text: 'Entity', link: '/api/classes/Entity' },
|
||||
{ text: 'Component', link: '/api/classes/Component' },
|
||||
{ text: 'EntitySystem', link: '/api/classes/EntitySystem' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '系统类',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: 'PassiveSystem', link: '/api/classes/PassiveSystem' },
|
||||
{ text: 'ProcessingSystem', link: '/api/classes/ProcessingSystem' },
|
||||
{ text: 'IntervalSystem', link: '/api/classes/IntervalSystem' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '工具类',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: 'Matcher', link: '/api/classes/Matcher' },
|
||||
{ text: 'Time', link: '/api/classes/Time' },
|
||||
{ text: 'PerformanceMonitor', link: '/api/classes/PerformanceMonitor' },
|
||||
{ text: 'DebugManager', link: '/api/classes/DebugManager' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '接口',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: 'IScene', link: '/api/interfaces/IScene' },
|
||||
{ text: 'IComponent', link: '/api/interfaces/IComponent' },
|
||||
{ text: 'ISystemBase', link: '/api/interfaces/ISystemBase' },
|
||||
{ text: 'ICoreConfig', link: '/api/interfaces/ICoreConfig' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '装饰器',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: '@ECSComponent', link: '/api/functions/ECSComponent' },
|
||||
{ text: '@ECSSystem', link: '/api/functions/ECSSystem' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '枚举',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: 'ECSEventType', link: '/api/enumerations/ECSEventType' },
|
||||
{ text: 'LogLevel', link: '/api/enumerations/LogLevel' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
socialLinks: [
|
||||
{ icon: 'github', link: 'https://github.com/esengine/ecs-framework' }
|
||||
],
|
||||
@@ -212,18 +259,8 @@ export default defineConfig({
|
||||
copyright: 'Copyright © 2025 ECS Framework'
|
||||
},
|
||||
|
||||
editLink: {
|
||||
pattern: 'https://github.com/esengine/ecs-framework/edit/master/docs/:path',
|
||||
text: '在 GitHub 上编辑此页'
|
||||
},
|
||||
|
||||
search: {
|
||||
provider: 'local'
|
||||
},
|
||||
|
||||
outline: {
|
||||
level: [2, 3],
|
||||
label: '在这个页面上'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -232,8 +269,6 @@ export default defineConfig({
|
||||
['link', { rel: 'icon', href: '/favicon.ico' }]
|
||||
],
|
||||
|
||||
// 使用自定义域名 esengine.cn 时,base 设置为 '/'
|
||||
// 如果部署到 GitHub Pages 子路径,改为 '/ecs-framework/'
|
||||
base: '/',
|
||||
cleanUrls: true,
|
||||
|
||||
@@ -244,4 +279,4 @@ export default defineConfig({
|
||||
dark: 'github-dark'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
85
docs/.vitepress/i18n/en.json
Normal file
85
docs/.vitepress/i18n/en.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"quickStart": "Quick Start",
|
||||
"guide": "Guide",
|
||||
"api": "API",
|
||||
"examples": "Examples",
|
||||
"workerDemo": "Worker System Demo",
|
||||
"lawnMowerDemo": "Lawn Mower Demo"
|
||||
},
|
||||
"sidebar": {
|
||||
"gettingStarted": "Getting Started",
|
||||
"quickStart": "Quick Start",
|
||||
"guideOverview": "Guide Overview",
|
||||
"coreConcepts": "Core Concepts",
|
||||
"entity": "Entity",
|
||||
"hierarchy": "Hierarchy",
|
||||
"component": "Component",
|
||||
"entityQuery": "Entity Query",
|
||||
"system": "System",
|
||||
"workerSystem": "Worker System (Multithreading)",
|
||||
"scene": "Scene",
|
||||
"sceneManager": "SceneManager",
|
||||
"worldManager": "WorldManager",
|
||||
"behaviorTree": "Behavior Tree",
|
||||
"btGettingStarted": "Getting Started",
|
||||
"btCoreConcepts": "Core Concepts",
|
||||
"btEditorGuide": "Editor Guide",
|
||||
"btEditorWorkflow": "Editor Workflow",
|
||||
"btCustomActions": "Custom Actions",
|
||||
"btCocosIntegration": "Cocos Creator Integration",
|
||||
"btLayaIntegration": "Laya Engine Integration",
|
||||
"btAdvancedUsage": "Advanced Usage",
|
||||
"btBestPractices": "Best Practices",
|
||||
"serialization": "Serialization",
|
||||
"eventSystem": "Event System",
|
||||
"timeAndTimers": "Time and Timers",
|
||||
"logging": "Logging",
|
||||
"advancedFeatures": "Advanced Features",
|
||||
"serviceContainer": "Service Container",
|
||||
"pluginSystem": "Plugin System",
|
||||
"platformAdapters": "Platform Adapters",
|
||||
"browserAdapter": "Browser Adapter",
|
||||
"wechatAdapter": "WeChat Mini Game Adapter",
|
||||
"nodejsAdapter": "Node.js Adapter",
|
||||
"examples": "Examples",
|
||||
"examplesOverview": "Examples Overview",
|
||||
"apiReference": "API Reference",
|
||||
"overview": "Overview",
|
||||
"coreClasses": "Core Classes",
|
||||
"systemClasses": "System Classes",
|
||||
"utilities": "Utilities",
|
||||
"interfaces": "Interfaces",
|
||||
"decorators": "Decorators",
|
||||
"enums": "Enums"
|
||||
},
|
||||
"home": {
|
||||
"title": "ESEngine - High-performance TypeScript ECS Framework",
|
||||
"quickLinks": "Quick Links",
|
||||
"viewDocs": "View Docs",
|
||||
"getStarted": "Get Started",
|
||||
"getStartedDesc": "From installation to your first ECS app, learn the core concepts in 5 minutes.",
|
||||
"aiSystem": "AI System",
|
||||
"behaviorTreeEditor": "Visual Behavior Tree Editor",
|
||||
"behaviorTreeDesc": "Built-in AI behavior tree system with visual editing and real-time debugging.",
|
||||
"coreFeatures": "Core Features",
|
||||
"ecsArchitecture": "High-performance ECS Architecture",
|
||||
"ecsArchitectureDesc": "Data-driven entity component system for large-scale entity processing with cache-friendly memory layout.",
|
||||
"typeSupport": "Full Type Support",
|
||||
"typeSupportDesc": "100% TypeScript with complete type definitions and compile-time checking for the best development experience.",
|
||||
"visualBehaviorTree": "Visual Behavior Tree",
|
||||
"visualBehaviorTreeDesc": "Built-in AI behavior tree system with visual editor, custom nodes, and real-time debugging.",
|
||||
"multiPlatform": "Multi-Platform Support",
|
||||
"multiPlatformDesc": "Support for browsers, Node.js, WeChat Mini Games, and seamless integration with major game engines.",
|
||||
"modularDesign": "Modular Design",
|
||||
"modularDesignDesc": "Core features packaged independently, import only what you need. Support for custom plugin extensions.",
|
||||
"devTools": "Developer Tools",
|
||||
"devToolsDesc": "Built-in performance monitoring, debugging tools, serialization system, and complete development toolchain.",
|
||||
"learnMore": "Learn more →"
|
||||
},
|
||||
"common": {
|
||||
"editOnGithub": "Edit this page on GitHub",
|
||||
"onThisPage": "On this page"
|
||||
}
|
||||
}
|
||||
21
docs/.vitepress/i18n/index.ts
Normal file
21
docs/.vitepress/i18n/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import en from './en.json'
|
||||
import zh from './zh.json'
|
||||
|
||||
export const messages = { en, zh }
|
||||
|
||||
export type Locale = 'en' | 'zh'
|
||||
|
||||
export function getLocaleMessages(locale: Locale) {
|
||||
return messages[locale] || messages.en
|
||||
}
|
||||
|
||||
// Helper to get nested key value
|
||||
export function t(messages: typeof en, key: string): string {
|
||||
const keys = key.split('.')
|
||||
let result: any = messages
|
||||
for (const k of keys) {
|
||||
result = result?.[k]
|
||||
if (result === undefined) return key
|
||||
}
|
||||
return result
|
||||
}
|
||||
85
docs/.vitepress/i18n/zh.json
Normal file
85
docs/.vitepress/i18n/zh.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"nav": {
|
||||
"home": "首页",
|
||||
"quickStart": "快速开始",
|
||||
"guide": "指南",
|
||||
"api": "API",
|
||||
"examples": "示例",
|
||||
"workerDemo": "Worker系统演示",
|
||||
"lawnMowerDemo": "割草机演示"
|
||||
},
|
||||
"sidebar": {
|
||||
"gettingStarted": "开始使用",
|
||||
"quickStart": "快速开始",
|
||||
"guideOverview": "指南概览",
|
||||
"coreConcepts": "核心概念",
|
||||
"entity": "实体类 (Entity)",
|
||||
"hierarchy": "层级系统 (Hierarchy)",
|
||||
"component": "组件系统 (Component)",
|
||||
"entityQuery": "实体查询系统",
|
||||
"system": "系统架构 (System)",
|
||||
"workerSystem": "Worker系统 (多线程)",
|
||||
"scene": "场景管理 (Scene)",
|
||||
"sceneManager": "SceneManager",
|
||||
"worldManager": "WorldManager",
|
||||
"behaviorTree": "行为树系统 (Behavior Tree)",
|
||||
"btGettingStarted": "快速开始",
|
||||
"btCoreConcepts": "核心概念",
|
||||
"btEditorGuide": "编辑器指南",
|
||||
"btEditorWorkflow": "编辑器工作流",
|
||||
"btCustomActions": "自定义动作组件",
|
||||
"btCocosIntegration": "Cocos Creator集成",
|
||||
"btLayaIntegration": "Laya引擎集成",
|
||||
"btAdvancedUsage": "高级用法",
|
||||
"btBestPractices": "最佳实践",
|
||||
"serialization": "序列化系统 (Serialization)",
|
||||
"eventSystem": "事件系统 (Event)",
|
||||
"timeAndTimers": "时间和定时器 (Time)",
|
||||
"logging": "日志系统 (Logger)",
|
||||
"advancedFeatures": "高级特性",
|
||||
"serviceContainer": "服务容器 (Service Container)",
|
||||
"pluginSystem": "插件系统 (Plugin System)",
|
||||
"platformAdapters": "平台适配器",
|
||||
"browserAdapter": "浏览器适配器",
|
||||
"wechatAdapter": "微信小游戏适配器",
|
||||
"nodejsAdapter": "Node.js适配器",
|
||||
"examples": "示例",
|
||||
"examplesOverview": "示例概览",
|
||||
"apiReference": "API 参考",
|
||||
"overview": "概述",
|
||||
"coreClasses": "核心类",
|
||||
"systemClasses": "系统类",
|
||||
"utilities": "工具类",
|
||||
"interfaces": "接口",
|
||||
"decorators": "装饰器",
|
||||
"enums": "枚举"
|
||||
},
|
||||
"home": {
|
||||
"title": "ESEngine - 高性能 TypeScript ECS 框架",
|
||||
"quickLinks": "快速入口",
|
||||
"viewDocs": "查看文档",
|
||||
"getStarted": "快速开始",
|
||||
"getStartedDesc": "从安装到创建第一个 ECS 应用,快速了解核心概念。",
|
||||
"aiSystem": "AI 系统",
|
||||
"behaviorTreeEditor": "行为树可视化编辑器",
|
||||
"behaviorTreeDesc": "内置 AI 行为树系统,支持可视化编辑和实时调试。",
|
||||
"coreFeatures": "核心特性",
|
||||
"ecsArchitecture": "高性能 ECS 架构",
|
||||
"ecsArchitectureDesc": "基于数据驱动的实体组件系统,支持大规模实体处理,缓存友好的内存布局。",
|
||||
"typeSupport": "完整类型支持",
|
||||
"typeSupportDesc": "100% TypeScript 编写,完整的类型定义和编译时检查,提供最佳的开发体验。",
|
||||
"visualBehaviorTree": "可视化行为树",
|
||||
"visualBehaviorTreeDesc": "内置 AI 行为树系统,提供可视化编辑器,支持自定义节点和实时调试。",
|
||||
"multiPlatform": "多平台支持",
|
||||
"multiPlatformDesc": "支持浏览器、Node.js、微信小游戏等多平台,可与主流游戏引擎无缝集成。",
|
||||
"modularDesign": "模块化设计",
|
||||
"modularDesignDesc": "核心功能独立打包,按需引入。支持自定义插件扩展,灵活适配不同项目。",
|
||||
"devTools": "开发者工具",
|
||||
"devToolsDesc": "内置性能监控、调试工具、序列化系统等,提供完整的开发工具链。",
|
||||
"learnMore": "了解更多 →"
|
||||
},
|
||||
"common": {
|
||||
"editOnGithub": "在 GitHub 上编辑此页",
|
||||
"onThisPage": "在这个页面上"
|
||||
}
|
||||
}
|
||||
422
docs/.vitepress/theme/components/ParticleHeroEn.vue
Normal file
422
docs/.vitepress/theme/components/ParticleHeroEn.vue
Normal file
@@ -0,0 +1,422 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const canvasRef = ref(null)
|
||||
let animationId = null
|
||||
let particles = []
|
||||
let animationStartTime = null
|
||||
let glowStartTime = null
|
||||
|
||||
// ESEngine particle colors - VS Code style colors (unified with editor)
|
||||
const colors = ['#569CD6', '#4EC9B0', '#9CDCFE', '#C586C0', '#DCDCAA']
|
||||
|
||||
class Particle {
|
||||
constructor(x, y, targetX, targetY) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
this.targetX = targetX
|
||||
this.targetY = targetY
|
||||
this.size = Math.random() * 2 + 1.5
|
||||
this.alpha = Math.random() * 0.5 + 0.5
|
||||
this.color = colors[Math.floor(Math.random() * colors.length)]
|
||||
}
|
||||
}
|
||||
|
||||
function createParticles(canvas, text, fontSize) {
|
||||
const tempCanvas = document.createElement('canvas')
|
||||
const tempCtx = tempCanvas.getContext('2d')
|
||||
if (!tempCtx) return []
|
||||
|
||||
tempCtx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`
|
||||
const textMetrics = tempCtx.measureText(text)
|
||||
const textWidth = textMetrics.width
|
||||
const textHeight = fontSize
|
||||
|
||||
tempCanvas.width = textWidth + 40
|
||||
tempCanvas.height = textHeight + 40
|
||||
tempCtx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`
|
||||
tempCtx.textAlign = 'center'
|
||||
tempCtx.textBaseline = 'middle'
|
||||
tempCtx.fillStyle = '#ffffff'
|
||||
tempCtx.fillText(text, tempCanvas.width / 2, tempCanvas.height / 2)
|
||||
|
||||
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height)
|
||||
const pixels = imageData.data
|
||||
const newParticles = []
|
||||
const gap = 3
|
||||
|
||||
const width = canvas.width / (window.devicePixelRatio || 1)
|
||||
const height = canvas.height / (window.devicePixelRatio || 1)
|
||||
const offsetX = (width - tempCanvas.width) / 2
|
||||
const offsetY = (height - tempCanvas.height) / 2
|
||||
|
||||
for (let y = 0; y < tempCanvas.height; y += gap) {
|
||||
for (let x = 0; x < tempCanvas.width; x += gap) {
|
||||
const index = (y * tempCanvas.width + x) * 4
|
||||
const alpha = pixels[index + 3] || 0
|
||||
|
||||
if (alpha > 128) {
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const distance = Math.random() * Math.max(width, height)
|
||||
|
||||
newParticles.push(new Particle(
|
||||
width / 2 + Math.cos(angle) * distance,
|
||||
height / 2 + Math.sin(angle) * distance,
|
||||
offsetX + x,
|
||||
offsetY + y
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newParticles
|
||||
}
|
||||
|
||||
function easeOutQuart(t) {
|
||||
return 1 - Math.pow(1 - t, 4)
|
||||
}
|
||||
|
||||
function easeOutCubic(t) {
|
||||
return 1 - Math.pow(1 - t, 3)
|
||||
}
|
||||
|
||||
function animate(canvas, ctx) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const width = canvas.width / dpr
|
||||
const height = canvas.height / dpr
|
||||
|
||||
const currentTime = performance.now()
|
||||
const duration = 2500
|
||||
const glowDuration = 600
|
||||
|
||||
const elapsed = currentTime - animationStartTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
const easedProgress = easeOutQuart(progress)
|
||||
|
||||
// Transparent background
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
// Calculate glow progress
|
||||
let glowProgress = 0
|
||||
if (progress >= 1) {
|
||||
if (glowStartTime === null) {
|
||||
glowStartTime = currentTime
|
||||
}
|
||||
glowProgress = Math.min((currentTime - glowStartTime) / glowDuration, 1)
|
||||
glowProgress = easeOutCubic(glowProgress)
|
||||
}
|
||||
|
||||
const text = 'ESEngine'
|
||||
const fontSize = Math.min(width / 4, height / 3, 80)
|
||||
const textY = height / 2
|
||||
|
||||
for (const particle of particles) {
|
||||
const moveProgress = Math.min(easedProgress * 1.2, 1)
|
||||
const currentX = particle.x + (particle.targetX - particle.x) * moveProgress
|
||||
const currentY = particle.y + (particle.targetY - particle.y) * moveProgress
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(currentX, currentY, particle.size, 0, Math.PI * 2)
|
||||
ctx.fillStyle = particle.color
|
||||
ctx.globalAlpha = particle.alpha * (1 - glowProgress * 0.3)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
if (glowProgress > 0) {
|
||||
ctx.save()
|
||||
ctx.shadowColor = '#3b9eff'
|
||||
ctx.shadowBlur = 30 * glowProgress
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${glowProgress})`
|
||||
ctx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(text, width / 2, textY)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
if (glowProgress >= 1) {
|
||||
const breathe = 0.8 + Math.sin(currentTime / 1000) * 0.2
|
||||
ctx.save()
|
||||
ctx.shadowColor = '#3b9eff'
|
||||
ctx.shadowBlur = 20 * breathe
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(text, width / 2, textY)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(() => animate(canvas, ctx))
|
||||
}
|
||||
|
||||
function initCanvas() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const container = canvas.parentElement
|
||||
const width = container.offsetWidth
|
||||
const height = container.offsetHeight
|
||||
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
const text = 'ESEngine'
|
||||
const fontSize = Math.min(width / 4, height / 3, 80)
|
||||
|
||||
particles = createParticles(canvas, text, fontSize)
|
||||
animationStartTime = performance.now()
|
||||
glowStartTime = null
|
||||
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId)
|
||||
}
|
||||
|
||||
animate(canvas, ctx)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initCanvas()
|
||||
window.addEventListener('resize', initCanvas)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId)
|
||||
}
|
||||
window.removeEventListener('resize', initCanvas)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="hero-section">
|
||||
<div class="hero-container">
|
||||
<!-- Left text area -->
|
||||
<div class="hero-text">
|
||||
<div class="hero-logo">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16" cy="16" r="14" stroke="#9147ff" stroke-width="2"/>
|
||||
<path d="M10 10h8v2h-6v3h5v2h-5v3h6v2h-8v-12z" fill="#9147ff"/>
|
||||
</svg>
|
||||
<span>ESENGINE</span>
|
||||
</div>
|
||||
<h1 class="hero-title">
|
||||
We build the framework.<br/>
|
||||
You create the game.
|
||||
</h1>
|
||||
<p class="hero-description">
|
||||
ESEngine is a high-performance TypeScript ECS framework for game developers.
|
||||
Whether 2D or 3D games, it helps you build scalable game architecture quickly.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a href="/en/guide/getting-started" class="btn-primary">Get Started</a>
|
||||
<a href="https://github.com/esengine/ecs-framework" class="btn-secondary" target="_blank">Learn More</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right particle animation area -->
|
||||
<div class="hero-visual">
|
||||
<div class="visual-container">
|
||||
<canvas ref="canvasRef" class="particle-canvas"></canvas>
|
||||
<div class="visual-label">
|
||||
<span class="label-title">Entity Component System</span>
|
||||
<span class="label-subtitle">High Performance Framework</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero-section {
|
||||
background: #0d0d0d;
|
||||
padding: 80px 0;
|
||||
min-height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 48px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.2fr;
|
||||
gap: 64px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Left text */
|
||||
.hero-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.hero-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 1.125rem;
|
||||
color: #707070;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px 28px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b9eff;
|
||||
color: #ffffff;
|
||||
border: 1px solid #3b9eff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5aadff;
|
||||
border-color: #5aadff;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #1a1a1a;
|
||||
color: #a0a0a0;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #252525;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hero-visual {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.visual-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: linear-gradient(135deg, #1a2a3a 0%, #1a1a1a 50%, #0d0d0d 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid #2a2a2a;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(59, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
.particle-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.visual-label {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.label-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.label-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.hero-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 48px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
padding: 48px 0;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.visual-container {
|
||||
max-width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,78 +1,84 @@
|
||||
/* ============================================
|
||||
ESEngine 文档站主题样式
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--vp-nav-height: 64px;
|
||||
|
||||
--es-bg-base: #1e1e1e;
|
||||
--es-bg-elevated: #252526;
|
||||
--es-bg-overlay: #2d2d2d;
|
||||
--es-bg-input: #3c3c3c;
|
||||
--es-bg-inset: #181818;
|
||||
--es-bg-hover: #2a2d2e;
|
||||
--es-bg-active: #37373d;
|
||||
--es-bg-sidebar: #262626;
|
||||
--es-bg-card: #2a2a2a;
|
||||
--es-bg-header: #2d2d2d;
|
||||
|
||||
--es-text-primary: #cccccc;
|
||||
--es-text-secondary: #9d9d9d;
|
||||
--es-text-tertiary: #6a6a6a;
|
||||
--es-text-inverse: #ffffff;
|
||||
--es-text-muted: #aaaaaa;
|
||||
--es-text-dim: #6a6a6a;
|
||||
|
||||
--es-font-xs: 11px;
|
||||
--es-font-sm: 12px;
|
||||
--es-font-base: 13px;
|
||||
--es-font-md: 14px;
|
||||
--es-font-lg: 16px;
|
||||
|
||||
--es-border-default: #3a3a3a;
|
||||
--es-border-subtle: #1a1a1a;
|
||||
--es-border-strong: #4a4a4a;
|
||||
|
||||
--es-primary: #3b82f6;
|
||||
--es-primary-hover: #2563eb;
|
||||
--es-success: #4ade80;
|
||||
--es-warning: #f59e0b;
|
||||
--es-error: #ef4444;
|
||||
--es-info: #3b82f6;
|
||||
|
||||
--es-selected: #3d5a80;
|
||||
--es-selected-hover: #4a6a90;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0d0d0d !important;
|
||||
}
|
||||
|
||||
.VPContent.has-sidebar {
|
||||
background: linear-gradient(180deg, #1e3a5f 0%, #152540 30vh, #0d1a2a 50vh, #0d0d0d 70vh) !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background: var(--es-bg-base) !important;
|
||||
}
|
||||
|
||||
html,
|
||||
html.dark {
|
||||
--vp-c-bg: #0d0d0d;
|
||||
--vp-c-bg-soft: #1a1a1a;
|
||||
--vp-c-bg-mute: #1f1f1f;
|
||||
--vp-c-bg-alt: #1a1a1a;
|
||||
--vp-c-text-1: #e0e0e0;
|
||||
--vp-c-text-2: #a0a0a0;
|
||||
--vp-c-text-3: #707070;
|
||||
--vp-c-divider: #2a2a2a;
|
||||
--vp-c-divider-light: #222222;
|
||||
--vp-c-bg: var(--es-bg-base);
|
||||
--vp-c-bg-soft: var(--es-bg-elevated);
|
||||
--vp-c-bg-mute: var(--es-bg-overlay);
|
||||
--vp-c-bg-alt: var(--es-bg-sidebar);
|
||||
--vp-c-text-1: var(--es-text-primary);
|
||||
--vp-c-text-2: var(--es-text-tertiary);
|
||||
--vp-c-text-3: var(--es-text-muted);
|
||||
--vp-c-divider: var(--es-border-default);
|
||||
--vp-c-divider-light: var(--es-border-subtle);
|
||||
}
|
||||
|
||||
html:not(.dark) {
|
||||
--vp-c-bg: #0d0d0d !important;
|
||||
--vp-c-bg-soft: #1a1a1a !important;
|
||||
--vp-c-bg-mute: #1f1f1f !important;
|
||||
--vp-c-bg-alt: #1a1a1a !important;
|
||||
--vp-c-text-1: #e0e0e0 !important;
|
||||
--vp-c-text-2: #a0a0a0 !important;
|
||||
--vp-c-text-3: #707070 !important;
|
||||
--vp-c-bg: var(--es-bg-base) !important;
|
||||
--vp-c-bg-soft: var(--es-bg-elevated) !important;
|
||||
--vp-c-bg-mute: var(--es-bg-overlay) !important;
|
||||
--vp-c-bg-alt: var(--es-bg-sidebar) !important;
|
||||
--vp-c-text-1: var(--es-text-primary) !important;
|
||||
--vp-c-text-2: var(--es-text-tertiary) !important;
|
||||
--vp-c-text-3: var(--es-text-muted) !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
--es-bg-base: #0d0d0d;
|
||||
--es-bg-sidebar: #1a1a1a;
|
||||
--es-bg-card: #1f1f1f;
|
||||
--es-bg-hover: #252525;
|
||||
--es-bg-selected: #0e4a7c;
|
||||
|
||||
--es-text-primary: #e0e0e0;
|
||||
--es-text-secondary: #a0a0a0;
|
||||
--es-text-tertiary: #707070;
|
||||
--es-text-inverse: #ffffff;
|
||||
|
||||
--es-border-default: #2a2a2a;
|
||||
--es-border-subtle: #222222;
|
||||
|
||||
--es-primary: #3b9eff;
|
||||
--es-warning: #c9a227;
|
||||
--es-info: #3b9eff;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
导航栏
|
||||
============================================ */
|
||||
.VPNav {
|
||||
background: #0d0d0d !important;
|
||||
border-bottom: 1px solid #2a2a2a !important;
|
||||
background: var(--es-bg-header) !important;
|
||||
border-bottom: 1px solid var(--es-border-subtle) !important;
|
||||
}
|
||||
|
||||
.VPNav .VPNavBar {
|
||||
background: #0d0d0d !important;
|
||||
background: var(--es-bg-header) !important;
|
||||
}
|
||||
|
||||
.VPNav .VPNavBar .wrapper {
|
||||
background: #0d0d0d !important;
|
||||
background: var(--es-bg-header) !important;
|
||||
}
|
||||
|
||||
.VPNav .VPNavBar::before,
|
||||
@@ -81,7 +87,7 @@ html:not(.dark) {
|
||||
}
|
||||
|
||||
.VPNavBar {
|
||||
background: #0d0d0d !important;
|
||||
background: var(--es-bg-header) !important;
|
||||
}
|
||||
|
||||
.VPNavBar::before {
|
||||
@@ -89,127 +95,120 @@ html:not(.dark) {
|
||||
}
|
||||
|
||||
.VPNavBarTitle .title {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--es-text-primary);
|
||||
font-weight: 500;
|
||||
font-size: var(--es-font-base);
|
||||
}
|
||||
|
||||
.VPNavBarMenuLink {
|
||||
color: #a0a0a0 !important;
|
||||
font-size: 14px !important;
|
||||
color: var(--es-text-secondary) !important;
|
||||
font-size: var(--es-font-sm) !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
.VPNavBarMenuLink:hover {
|
||||
color: #ffffff !important;
|
||||
color: var(--es-text-primary) !important;
|
||||
}
|
||||
|
||||
.VPNavBarMenuLink.active {
|
||||
color: #ffffff !important;
|
||||
color: var(--es-text-primary) !important;
|
||||
}
|
||||
|
||||
.VPNavBarSearch .DocSearch-Button {
|
||||
background: #1a1a1a !important;
|
||||
border: 1px solid #2a2a2a !important;
|
||||
border-radius: 6px;
|
||||
background: var(--es-bg-input) !important;
|
||||
border: 1px solid var(--es-border-default) !important;
|
||||
border-radius: 2px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
侧边栏
|
||||
============================================ */
|
||||
.VPSidebar {
|
||||
background: transparent !important;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
padding: 16px 0 !important;
|
||||
width: 280px !important;
|
||||
}
|
||||
|
||||
.VPSidebar .nav {
|
||||
padding: 0 16px;
|
||||
background: var(--es-bg-sidebar) !important;
|
||||
border-right: 1px solid var(--es-border-subtle) !important;
|
||||
}
|
||||
|
||||
.VPSidebarItem.level-0 > .item {
|
||||
padding: 12px 0 6px 0;
|
||||
padding: 8px 0 4px 0;
|
||||
}
|
||||
|
||||
.VPSidebarItem.level-0 > .item > .text {
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
color: #808080;
|
||||
font-weight: 600;
|
||||
font-size: var(--es-font-xs);
|
||||
color: var(--es-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.VPSidebarItem .link {
|
||||
padding: 6px 12px;
|
||||
padding: 4px 8px;
|
||||
margin: 1px 0;
|
||||
border-radius: 4px;
|
||||
color: #9d9d9d;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
color: var(--es-text-primary);
|
||||
font-size: var(--es-font-sm);
|
||||
transition: all 0.1s ease;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.VPSidebarItem .link:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--es-text-inverse);
|
||||
}
|
||||
|
||||
.VPSidebarItem.is-active > .item > .link {
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
border-left: 2px solid #3b9eff;
|
||||
background: var(--es-selected);
|
||||
color: var(--es-text-inverse);
|
||||
border-left: 2px solid var(--es-primary);
|
||||
}
|
||||
|
||||
.VPSidebarItem.is-active > .item > .link:hover {
|
||||
background: var(--es-selected-hover);
|
||||
}
|
||||
|
||||
.VPSidebarItem.level-1 .link {
|
||||
padding-left: 24px;
|
||||
font-size: 13px;
|
||||
padding-left: 20px;
|
||||
font-size: var(--es-font-sm);
|
||||
}
|
||||
|
||||
.VPSidebarItem.level-2 .link {
|
||||
padding-left: 36px;
|
||||
font-size: 13px;
|
||||
padding-left: 32px;
|
||||
font-size: var(--es-font-sm);
|
||||
}
|
||||
|
||||
.VPSidebarItem .caret {
|
||||
color: #606060;
|
||||
color: var(--es-text-secondary);
|
||||
}
|
||||
|
||||
.VPSidebarItem .caret:hover {
|
||||
color: #9d9d9d;
|
||||
color: var(--es-text-primary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
内容区域
|
||||
============================================ */
|
||||
.VPContent {
|
||||
background: transparent !important;
|
||||
background: var(--es-bg-card) !important;
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
.VPContent.has-sidebar {
|
||||
background: var(--es-bg-card) !important;
|
||||
}
|
||||
|
||||
/* 首页布局修复 | Home page layout fix */
|
||||
.VPPage {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
.Layout > .VPContent {
|
||||
padding-top: var(--vp-nav-height) !important;
|
||||
}
|
||||
|
||||
.VPDoc {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.VPDoc .container {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.VPDoc .content {
|
||||
max-width: 860px !important;
|
||||
padding: 80px 60px 48px !important;
|
||||
}
|
||||
|
||||
.VPNavBar {
|
||||
background: #0d0d0d !important;
|
||||
}
|
||||
|
||||
.VPNavBar .content {
|
||||
background: #0d0d0d !important;
|
||||
background: var(--es-bg-header) !important;
|
||||
}
|
||||
|
||||
.VPNavBar .content-body {
|
||||
background: #0d0d0d !important;
|
||||
background: var(--es-bg-header) !important;
|
||||
}
|
||||
|
||||
.VPNavBar .divider {
|
||||
@@ -217,16 +216,16 @@ html:not(.dark) {
|
||||
}
|
||||
|
||||
.VPLocalNav {
|
||||
background: #0d0d0d !important;
|
||||
border-bottom: 1px solid #2a2a2a !important;
|
||||
background: var(--es-bg-header) !important;
|
||||
border-bottom: 1px solid var(--es-border-subtle) !important;
|
||||
}
|
||||
|
||||
.VPNavScreenMenu {
|
||||
background: #0d0d0d !important;
|
||||
background: var(--es-bg-base) !important;
|
||||
}
|
||||
|
||||
.VPNavScreen {
|
||||
background: #0d0d0d !important;
|
||||
background: var(--es-bg-base) !important;
|
||||
}
|
||||
|
||||
.curtain {
|
||||
@@ -248,68 +247,71 @@ html:not(.dark) {
|
||||
}
|
||||
|
||||
.vp-doc {
|
||||
color: #e0e0e0;
|
||||
color: var(--es-text-primary);
|
||||
}
|
||||
|
||||
.vp-doc h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
font-size: var(--es-font-lg);
|
||||
font-weight: 600;
|
||||
color: var(--es-text-inverse);
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.vp-doc h2 {
|
||||
font-size: 24px;
|
||||
font-size: var(--es-font-md);
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
color: var(--es-text-inverse);
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
margin-top: 48px;
|
||||
margin-bottom: 20px;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 12px;
|
||||
padding: 6px 12px;
|
||||
background: var(--es-bg-header);
|
||||
border-left: 3px solid var(--es-primary);
|
||||
}
|
||||
|
||||
.vp-doc h3 {
|
||||
font-size: 18px;
|
||||
font-size: var(--es-font-base);
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--es-text-primary);
|
||||
margin-top: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vp-doc p {
|
||||
color: #a0a0a0;
|
||||
line-height: 1.8;
|
||||
font-size: 15px;
|
||||
margin: 20px 0;
|
||||
color: var(--es-text-primary);
|
||||
line-height: 1.7;
|
||||
font-size: var(--es-font-base);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.vp-doc ul,
|
||||
.vp-doc ol {
|
||||
padding-left: 24px;
|
||||
margin: 20px 0;
|
||||
padding-left: 20px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.vp-doc li {
|
||||
line-height: 1.8;
|
||||
margin: 8px 0;
|
||||
color: #a0a0a0;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
margin: 4px 0;
|
||||
color: var(--es-text-primary);
|
||||
font-size: var(--es-font-base);
|
||||
}
|
||||
|
||||
.vp-doc li::marker {
|
||||
color: #707070;
|
||||
color: var(--es-text-secondary);
|
||||
}
|
||||
|
||||
.vp-doc strong {
|
||||
color: #ffffff;
|
||||
color: var(--es-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vp-doc a {
|
||||
color: #3b9eff;
|
||||
color: var(--es-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -317,12 +319,9 @@ html:not(.dark) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
右侧大纲
|
||||
============================================ */
|
||||
.VPDocAside {
|
||||
padding-left: 32px;
|
||||
border-left: 1px solid #2a2a2a;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--es-border-subtle);
|
||||
}
|
||||
|
||||
.VPDocAsideOutline {
|
||||
@@ -336,71 +335,66 @@ html:not(.dark) {
|
||||
}
|
||||
|
||||
.VPDocAsideOutline .outline-title {
|
||||
font-size: 14px;
|
||||
font-size: var(--es-font-xs);
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: #ffffff;
|
||||
padding-bottom: 16px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--es-text-secondary);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.VPDocAsideOutline .outline-link {
|
||||
color: #707070;
|
||||
font-size: 13px;
|
||||
padding: 6px 0;
|
||||
line-height: 1.5;
|
||||
color: var(--es-text-secondary);
|
||||
font-size: var(--es-font-xs);
|
||||
padding: 4px 0;
|
||||
line-height: 1.4;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.VPDocAsideOutline .outline-link:hover {
|
||||
color: #a0a0a0;
|
||||
color: var(--es-text-primary);
|
||||
}
|
||||
|
||||
.VPDocAsideOutline .outline-link.active {
|
||||
color: #3b9eff;
|
||||
color: var(--es-primary);
|
||||
}
|
||||
|
||||
.VPDocAsideOutline .outline-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
代码块
|
||||
============================================ */
|
||||
div[class*='language-'] {
|
||||
background: #1a1a1a !important;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
background: var(--es-bg-inset) !important;
|
||||
border: 1px solid var(--es-border-default);
|
||||
border-radius: 2px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.vp-code-group .tabs {
|
||||
background: #1f1f1f;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
background: var(--es-bg-header);
|
||||
border-bottom: 1px solid var(--es-border-subtle);
|
||||
}
|
||||
|
||||
.vp-doc :not(pre) > code {
|
||||
background: #1f1f1f;
|
||||
color: #3b9eff;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
background: var(--es-bg-input);
|
||||
color: var(--es-primary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
font-size: var(--es-font-xs);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
表格
|
||||
============================================ */
|
||||
.vp-doc table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-collapse: collapse;
|
||||
margin: 24px 0;
|
||||
margin: 16px 0;
|
||||
font-size: var(--es-font-sm);
|
||||
}
|
||||
|
||||
.vp-doc tr {
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
border-bottom: 1px solid var(--es-border-subtle);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -408,224 +402,193 @@ div[class*='language-'] {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.vp-doc tr:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.vp-doc th {
|
||||
background: #1f1f1f;
|
||||
background: var(--es-bg-header);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #a0a0a0;
|
||||
font-size: var(--es-font-xs);
|
||||
color: var(--es-text-secondary);
|
||||
text-align: left;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--es-border-subtle);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.vp-doc td {
|
||||
font-size: 15px;
|
||||
color: #e0e0e0;
|
||||
padding: 14px 20px;
|
||||
font-size: var(--es-font-sm);
|
||||
color: var(--es-text-primary);
|
||||
padding: 8px 12px;
|
||||
vertical-align: top;
|
||||
line-height: 1.6;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.vp-doc td:first-child {
|
||||
font-weight: 500;
|
||||
color: #a0a0a0;
|
||||
min-width: 120px;
|
||||
color: var(--es-text-primary);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
提示框
|
||||
============================================ */
|
||||
.vp-doc .warning,
|
||||
.vp-doc .custom-block.warning {
|
||||
background: rgba(201, 162, 39, 0.08);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border: none;
|
||||
border-left: 4px solid #c9a227;
|
||||
border-radius: 0 8px 8px 0;
|
||||
padding: 16px 24px;
|
||||
margin: 24px 0;
|
||||
border-left: 3px solid var(--es-warning);
|
||||
border-radius: 0 2px 2px 0;
|
||||
padding: 10px 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.vp-doc .warning .custom-block-title,
|
||||
.vp-doc .custom-block.warning .custom-block-title {
|
||||
color: #c9a227;
|
||||
color: var(--es-warning);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
font-size: var(--es-font-xs);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vp-doc .warning p {
|
||||
color: #a0a0a0;
|
||||
color: var(--es-text-primary);
|
||||
margin: 0;
|
||||
font-size: var(--es-font-xs);
|
||||
}
|
||||
|
||||
.vp-doc .tip,
|
||||
.vp-doc .custom-block.tip {
|
||||
background: rgba(59, 158, 255, 0.08);
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border: none;
|
||||
border-left: 4px solid #3b9eff;
|
||||
border-radius: 0 8px 8px 0;
|
||||
padding: 16px 24px;
|
||||
margin: 24px 0;
|
||||
border-left: 3px solid var(--es-primary);
|
||||
border-radius: 0 2px 2px 0;
|
||||
padding: 10px 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.vp-doc .tip .custom-block-title,
|
||||
.vp-doc .custom-block.tip .custom-block-title {
|
||||
color: #3b9eff;
|
||||
color: var(--es-primary);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
font-size: var(--es-font-xs);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vp-doc .tip p {
|
||||
color: #a0a0a0;
|
||||
color: var(--es-text-primary);
|
||||
margin: 0;
|
||||
font-size: var(--es-font-xs);
|
||||
}
|
||||
|
||||
.vp-doc .info,
|
||||
.vp-doc .custom-block.info {
|
||||
background: rgba(78, 201, 176, 0.08);
|
||||
background: rgba(74, 222, 128, 0.08);
|
||||
border: none;
|
||||
border-left: 4px solid #4ec9b0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
padding: 16px 24px;
|
||||
margin: 24px 0;
|
||||
border-left: 3px solid var(--es-success);
|
||||
border-radius: 0 2px 2px 0;
|
||||
padding: 10px 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.vp-doc .info .custom-block-title,
|
||||
.vp-doc .custom-block.info .custom-block-title {
|
||||
color: #4ec9b0;
|
||||
color: var(--es-success);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
font-size: var(--es-font-xs);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vp-doc .danger,
|
||||
.vp-doc .custom-block.danger {
|
||||
background: rgba(244, 135, 113, 0.08);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: none;
|
||||
border-left: 4px solid #f48771;
|
||||
border-radius: 0 8px 8px 0;
|
||||
padding: 16px 24px;
|
||||
margin: 24px 0;
|
||||
border-left: 3px solid var(--es-error);
|
||||
border-radius: 0 2px 2px 0;
|
||||
padding: 10px 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.vp-doc .danger .custom-block-title,
|
||||
.vp-doc .custom-block.danger .custom-block-title {
|
||||
color: #f48771;
|
||||
color: var(--es-error);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
font-size: var(--es-font-xs);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
卡片样式
|
||||
============================================ */
|
||||
.vp-doc .card {
|
||||
background: #1f1f1f;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin: 24px 0;
|
||||
background: var(--es-bg-sidebar);
|
||||
border: 1px solid var(--es-border-subtle);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.vp-doc .card-title {
|
||||
font-size: 18px;
|
||||
font-size: var(--es-font-sm);
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin-bottom: 8px;
|
||||
color: var(--es-text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.vp-doc .card-description {
|
||||
font-size: 14px;
|
||||
color: #707070;
|
||||
line-height: 1.6;
|
||||
font-size: var(--es-font-xs);
|
||||
color: var(--es-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
标签样式
|
||||
============================================ */
|
||||
.vp-doc .tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
padding: 2px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 16px;
|
||||
color: #a0a0a0;
|
||||
font-size: 13px;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--es-border-default);
|
||||
border-radius: 2px;
|
||||
color: var(--es-text-secondary);
|
||||
font-size: var(--es-font-xs);
|
||||
margin-right: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
链接行样式
|
||||
============================================ */
|
||||
.vp-doc .link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
color: #a0a0a0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.vp-doc .link-row a {
|
||||
color: #3b9eff;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
页脚
|
||||
============================================ */
|
||||
.VPFooter {
|
||||
background: #1a1a1a !important;
|
||||
border-top: 1px solid #2a2a2a !important;
|
||||
background: var(--es-bg-sidebar) !important;
|
||||
border-top: 1px solid var(--es-border-subtle) !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
滚动条
|
||||
============================================ */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: var(--es-bg-card);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3a3a3a;
|
||||
background: var(--es-border-strong);
|
||||
border-radius: 4px;
|
||||
border: 2px solid var(--es-bg-card);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #4a4a4a;
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
首页专用样式
|
||||
============================================ */
|
||||
.home-container {
|
||||
max-width: 1200px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.home-section {
|
||||
padding: 48px 0;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
响应式
|
||||
============================================ */
|
||||
@media (max-width: 960px) {
|
||||
.VPDoc .content {
|
||||
padding: 24px !important;
|
||||
}
|
||||
|
||||
.VPSidebar {
|
||||
width: 100% !important;
|
||||
padding: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import ParticleHero from './components/ParticleHero.vue'
|
||||
import ParticleHeroEn from './components/ParticleHeroEn.vue'
|
||||
import FeatureCard from './components/FeatureCard.vue'
|
||||
import './custom.css'
|
||||
|
||||
@@ -7,6 +8,7 @@ export default {
|
||||
extends: DefaultTheme,
|
||||
enhanceApp({ app }) {
|
||||
app.component('ParticleHero', ParticleHero)
|
||||
app.component('ParticleHeroEn', ParticleHeroEn)
|
||||
app.component('FeatureCard', FeatureCard)
|
||||
}
|
||||
}
|
||||
|
||||
412
docs/en/guide/getting-started.md
Normal file
412
docs/en/guide/getting-started.md
Normal file
@@ -0,0 +1,412 @@
|
||||
# Quick Start
|
||||
|
||||
This guide will help you get started with ECS Framework, from installation to creating your first ECS application.
|
||||
|
||||
## Installation
|
||||
|
||||
### NPM Installation
|
||||
|
||||
```bash
|
||||
# Using npm
|
||||
npm install @esengine/ecs-framework
|
||||
```
|
||||
|
||||
## Initialize Core
|
||||
|
||||
### Basic Initialization
|
||||
|
||||
The core of ECS Framework is the `Core` class, a singleton that manages the entire framework lifecycle.
|
||||
|
||||
```typescript
|
||||
import { Core } from '@esengine/ecs-framework'
|
||||
|
||||
// Method 1: Using config object (recommended)
|
||||
const core = Core.create({
|
||||
debug: true, // Enable debug mode for detailed logs and performance monitoring
|
||||
debugConfig: { // Optional: Advanced debug configuration
|
||||
enabled: false, // Whether to enable WebSocket debug server
|
||||
websocketUrl: 'ws://localhost:8080',
|
||||
debugFrameRate: 30, // Debug data send frame rate
|
||||
channels: {
|
||||
entities: true,
|
||||
systems: true,
|
||||
performance: true,
|
||||
components: true,
|
||||
scenes: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Method 2: Simplified creation (backward compatible)
|
||||
const core = Core.create(true); // Equivalent to { debug: true }
|
||||
|
||||
// Method 3: Production environment configuration
|
||||
const core = Core.create({
|
||||
debug: false // Disable debug in production
|
||||
});
|
||||
```
|
||||
|
||||
### Core Configuration Details
|
||||
|
||||
```typescript
|
||||
interface ICoreConfig {
|
||||
/** Enable debug mode - affects log level and performance monitoring */
|
||||
debug?: boolean;
|
||||
|
||||
/** Advanced debug configuration - for dev tools integration */
|
||||
debugConfig?: {
|
||||
enabled: boolean; // Enable debug server
|
||||
websocketUrl: string; // WebSocket server URL
|
||||
autoReconnect?: boolean; // Auto reconnect
|
||||
debugFrameRate?: 60 | 30 | 15; // Debug data send frame rate
|
||||
channels: { // Data channel configuration
|
||||
entities: boolean; // Entity data
|
||||
systems: boolean; // System data
|
||||
performance: boolean; // Performance data
|
||||
components: boolean; // Component data
|
||||
scenes: boolean; // Scene data
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Core Instance Management
|
||||
|
||||
Core uses singleton pattern, accessible via static property after creation:
|
||||
|
||||
```typescript
|
||||
// Create instance
|
||||
const core = Core.create(true);
|
||||
|
||||
// Get created instance
|
||||
const instance = Core.Instance; // Returns current instance, null if not created
|
||||
```
|
||||
|
||||
### Game Loop Integration
|
||||
|
||||
**Important**: Before creating entities and systems, you need to understand how to integrate ECS Framework into your game engine.
|
||||
|
||||
`Core.update(deltaTime)` is the framework heartbeat, must be called every frame. It handles:
|
||||
- Updating the built-in Time class
|
||||
- Updating all global managers (timers, object pools, etc.)
|
||||
- Updating all entity systems in all scenes
|
||||
- Processing entity creation and destruction
|
||||
- Collecting performance data (in debug mode)
|
||||
|
||||
See engine integration examples: [Game Engine Integration](#game-engine-integration)
|
||||
|
||||
## Create Your First ECS Application
|
||||
|
||||
### 1. Define Components
|
||||
|
||||
Components are pure data containers that store entity state:
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent } from '@esengine/ecs-framework'
|
||||
|
||||
// Position component
|
||||
@ECSComponent('Position')
|
||||
class Position extends Component {
|
||||
x: number = 0
|
||||
y: number = 0
|
||||
|
||||
constructor(x: number = 0, y: number = 0) {
|
||||
super()
|
||||
this.x = x
|
||||
this.y = y
|
||||
}
|
||||
}
|
||||
|
||||
// Velocity component
|
||||
@ECSComponent('Velocity')
|
||||
class Velocity extends Component {
|
||||
dx: number = 0
|
||||
dy: number = 0
|
||||
|
||||
constructor(dx: number = 0, dy: number = 0) {
|
||||
super()
|
||||
this.dx = dx
|
||||
this.dy = dy
|
||||
}
|
||||
}
|
||||
|
||||
// Sprite component
|
||||
@ECSComponent('Sprite')
|
||||
class Sprite extends Component {
|
||||
texture: string = ''
|
||||
width: number = 32
|
||||
height: number = 32
|
||||
|
||||
constructor(texture: string, width: number = 32, height: number = 32) {
|
||||
super()
|
||||
this.texture = texture
|
||||
this.width = width
|
||||
this.height = height
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create Entity Systems
|
||||
|
||||
Systems contain game logic and process entities with specific components. ECS Framework provides Matcher-based entity filtering:
|
||||
|
||||
```typescript
|
||||
import { EntitySystem, Matcher, Time, ECSSystem } from '@esengine/ecs-framework'
|
||||
|
||||
// Movement system - handles position and velocity
|
||||
@ECSSystem('MovementSystem')
|
||||
class MovementSystem extends EntitySystem {
|
||||
|
||||
constructor() {
|
||||
// Use Matcher to define target entities: must have both Position and Velocity
|
||||
super(Matcher.empty().all(Position, Velocity))
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
// process method receives all matching entities
|
||||
for (const entity of entities) {
|
||||
const position = entity.getComponent(Position)!
|
||||
const velocity = entity.getComponent(Velocity)!
|
||||
|
||||
// Update position (using framework's Time class)
|
||||
position.x += velocity.dx * Time.deltaTime
|
||||
position.y += velocity.dy * Time.deltaTime
|
||||
|
||||
// Boundary check example
|
||||
if (position.x < 0) position.x = 0
|
||||
if (position.y < 0) position.y = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render system - handles visible objects
|
||||
@ECSSystem('RenderSystem')
|
||||
class RenderSystem extends EntitySystem {
|
||||
|
||||
constructor() {
|
||||
// Must have Position and Sprite, optional Velocity (for direction)
|
||||
super(Matcher.empty().all(Position, Sprite).any(Velocity))
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
for (const entity of entities) {
|
||||
const position = entity.getComponent(Position)!
|
||||
const sprite = entity.getComponent(Sprite)!
|
||||
const velocity = entity.getComponent(Velocity) // May be null
|
||||
|
||||
// Flip sprite based on velocity direction (optional logic)
|
||||
let flipX = false
|
||||
if (velocity && velocity.dx < 0) {
|
||||
flipX = true
|
||||
}
|
||||
|
||||
// Render logic (pseudocode here)
|
||||
this.drawSprite(sprite.texture, position.x, position.y, sprite.width, sprite.height, flipX)
|
||||
}
|
||||
}
|
||||
|
||||
private drawSprite(texture: string, x: number, y: number, width: number, height: number, flipX: boolean = false) {
|
||||
// Actual render implementation depends on your game engine
|
||||
const direction = flipX ? '<-' : '->'
|
||||
console.log(`Render ${texture} at (${x.toFixed(1)}, ${y.toFixed(1)}) direction: ${direction}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### 3. Create Scene
|
||||
|
||||
Recommended to extend Scene class for custom scenes:
|
||||
|
||||
```typescript
|
||||
import { Scene } from '@esengine/ecs-framework'
|
||||
|
||||
// Recommended: Extend Scene for custom scene
|
||||
class GameScene extends Scene {
|
||||
|
||||
initialize(): void {
|
||||
// Scene initialization logic
|
||||
this.name = "MainScene";
|
||||
|
||||
// Add systems to scene
|
||||
this.addSystem(new MovementSystem());
|
||||
this.addSystem(new RenderSystem());
|
||||
}
|
||||
|
||||
onStart(): void {
|
||||
// Logic when scene starts running
|
||||
console.log("Game scene started");
|
||||
}
|
||||
|
||||
unload(): void {
|
||||
// Cleanup logic when scene unloads
|
||||
console.log("Game scene unloaded");
|
||||
}
|
||||
}
|
||||
|
||||
// Create and set scene
|
||||
const gameScene = new GameScene();
|
||||
Core.setScene(gameScene);
|
||||
```
|
||||
|
||||
### 4. Create Entities
|
||||
|
||||
```typescript
|
||||
// Create player entity
|
||||
const player = gameScene.createEntity("Player");
|
||||
player.addComponent(new Position(100, 100));
|
||||
player.addComponent(new Velocity(50, 30)); // Move 50px/sec (x), 30px/sec (y)
|
||||
player.addComponent(new Sprite("player.png", 64, 64));
|
||||
```
|
||||
|
||||
## Scene Management
|
||||
|
||||
Core has built-in scene management, very simple to use:
|
||||
|
||||
```typescript
|
||||
import { Core, Scene } from '@esengine/ecs-framework';
|
||||
|
||||
// Initialize Core
|
||||
Core.create({ debug: true });
|
||||
|
||||
// Create and set scene
|
||||
class GameScene extends Scene {
|
||||
initialize(): void {
|
||||
this.name = "GamePlay";
|
||||
this.addSystem(new MovementSystem());
|
||||
this.addSystem(new RenderSystem());
|
||||
}
|
||||
}
|
||||
|
||||
const gameScene = new GameScene();
|
||||
Core.setScene(gameScene);
|
||||
|
||||
// Game loop (auto-updates scene)
|
||||
function gameLoop(deltaTime: number) {
|
||||
Core.update(deltaTime); // Auto-updates global services and scene
|
||||
}
|
||||
|
||||
// Switch scenes
|
||||
Core.loadScene(new MenuScene()); // Delayed switch (next frame)
|
||||
Core.setScene(new GameScene()); // Immediate switch
|
||||
|
||||
// Access current scene
|
||||
const currentScene = Core.scene;
|
||||
|
||||
// Using fluent API
|
||||
const player = Core.ecsAPI?.createEntity('Player')
|
||||
.addComponent(Position, 100, 100)
|
||||
.addComponent(Velocity, 50, 0);
|
||||
```
|
||||
|
||||
### Advanced: Using WorldManager for Multi-World
|
||||
|
||||
Only for complex server-side applications (MMO game servers, game room systems, etc.):
|
||||
|
||||
```typescript
|
||||
import { Core, WorldManager } from '@esengine/ecs-framework';
|
||||
|
||||
// Initialize Core
|
||||
Core.create({ debug: true });
|
||||
|
||||
// Get WorldManager from service container (Core auto-creates and registers it)
|
||||
const worldManager = Core.services.resolve(WorldManager);
|
||||
|
||||
// Create multiple independent game worlds
|
||||
const room1 = worldManager.createWorld('room_001');
|
||||
const room2 = worldManager.createWorld('room_002');
|
||||
|
||||
// Create scenes in each world
|
||||
const gameScene1 = room1.createScene('game', new GameScene());
|
||||
const gameScene2 = room2.createScene('game', new GameScene());
|
||||
|
||||
// Activate scenes
|
||||
room1.setSceneActive('game', true);
|
||||
room2.setSceneActive('game', true);
|
||||
|
||||
// Game loop (need to manually update worlds)
|
||||
function gameLoop(deltaTime: number) {
|
||||
Core.update(deltaTime); // Update global services
|
||||
worldManager.updateAll(); // Manually update all worlds
|
||||
}
|
||||
```
|
||||
|
||||
## Game Engine Integration
|
||||
|
||||
### Laya Engine Integration
|
||||
|
||||
```typescript
|
||||
import { Stage } from "laya/display/Stage";
|
||||
import { Laya } from "Laya";
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
|
||||
// Initialize Laya
|
||||
Laya.init(800, 600).then(() => {
|
||||
// Initialize ECS
|
||||
Core.create(true);
|
||||
Core.setScene(new GameScene());
|
||||
|
||||
// Start game loop
|
||||
Laya.timer.frameLoop(1, this, () => {
|
||||
const deltaTime = Laya.timer.delta / 1000;
|
||||
Core.update(deltaTime); // Auto-updates global services and scene
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Cocos Creator Integration
|
||||
|
||||
```typescript
|
||||
import { Component, _decorator } from 'cc';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
|
||||
const { ccclass } = _decorator;
|
||||
|
||||
@ccclass('ECSGameManager')
|
||||
export class ECSGameManager extends Component {
|
||||
onLoad() {
|
||||
// Initialize ECS
|
||||
Core.create(true);
|
||||
Core.setScene(new GameScene());
|
||||
}
|
||||
|
||||
update(deltaTime: number) {
|
||||
// Auto-updates global services and scene
|
||||
Core.update(deltaTime);
|
||||
}
|
||||
|
||||
onDestroy() {
|
||||
// Cleanup resources
|
||||
Core.destroy();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Next Steps
|
||||
|
||||
You've successfully created your first ECS application! Next you can:
|
||||
|
||||
- Check the complete [API Documentation](/api/README)
|
||||
- Explore more [practical examples](/examples/)
|
||||
|
||||
## FAQ
|
||||
|
||||
### Why isn't my system executing?
|
||||
|
||||
Ensure:
|
||||
1. System is added to scene: `this.addSystem(system)` (in Scene's initialize method)
|
||||
2. Scene is set: `Core.setScene(scene)`
|
||||
3. Game loop is calling: `Core.update(deltaTime)`
|
||||
|
||||
### How to debug ECS applications?
|
||||
|
||||
Enable debug mode:
|
||||
|
||||
```typescript
|
||||
Core.create({ debug: true })
|
||||
|
||||
// Get debug data
|
||||
const debugData = Core.getDebugData()
|
||||
console.log(debugData)
|
||||
```
|
||||
43
docs/en/guide/index.md
Normal file
43
docs/en/guide/index.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Guide
|
||||
|
||||
Welcome to the ECS Framework Guide. This guide covers the core concepts and usage of the framework.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### [Entity](/guide/entity)
|
||||
Learn the basics of ECS architecture - how to use entities, lifecycle management, and best practices.
|
||||
|
||||
### [Component](/guide/component)
|
||||
Learn how to create and use components for modular game feature design.
|
||||
|
||||
### [System](/guide/system)
|
||||
Master system development to implement game logic processing.
|
||||
|
||||
### [Entity Query & Matcher](/guide/entity-query)
|
||||
Learn to use Matcher for entity filtering and queries with `all`, `any`, `none`, `nothing` conditions.
|
||||
|
||||
### [Scene](/guide/scene)
|
||||
Understand scene lifecycle, system management, and entity container features.
|
||||
|
||||
### [Event System](/guide/event-system)
|
||||
Master the type-safe event system for component communication and system coordination.
|
||||
|
||||
### [Serialization](/guide/serialization)
|
||||
Master serialization for scenes, entities, and components. Supports full and incremental serialization for game saves, network sync, and more.
|
||||
|
||||
### [Time and Timers](/guide/time-and-timers)
|
||||
Learn time management and timer systems for precise game logic timing control.
|
||||
|
||||
### [Logging](/guide/logging)
|
||||
Master the leveled logging system for debugging, monitoring, and error tracking.
|
||||
|
||||
### [Platform Adapter](/guide/platform-adapter)
|
||||
Learn how to implement and register platform adapters for browsers, mini-games, Node.js, and more.
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### [Service Container](/guide/service-container)
|
||||
Master dependency injection and service management for loosely-coupled architecture.
|
||||
|
||||
### [Plugin System](/guide/plugin-system)
|
||||
Learn how to develop and use plugins to extend framework functionality.
|
||||
317
docs/en/index.md
Normal file
317
docs/en/index.md
Normal file
@@ -0,0 +1,317 @@
|
||||
---
|
||||
layout: page
|
||||
title: ESEngine - High-performance TypeScript ECS Framework
|
||||
---
|
||||
|
||||
<ParticleHeroEn />
|
||||
|
||||
<section class="news-section">
|
||||
<div class="news-container">
|
||||
<div class="news-header">
|
||||
<h2 class="news-title">Quick Links</h2>
|
||||
<a href="/en/guide/" class="news-more">View Docs</a>
|
||||
</div>
|
||||
<div class="news-grid">
|
||||
<a href="/en/guide/getting-started" class="news-card">
|
||||
<div class="news-card-image" style="background: linear-gradient(135deg, #1e3a5f 0%, #1e1e1e 100%);">
|
||||
<div class="news-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"><path fill="#4fc1ff" d="M12 3L1 9l4 2.18v6L12 21l7-3.82v-6l2-1.09V17h2V9zm6.82 6L12 12.72L5.18 9L12 5.28zM17 16l-5 2.72L7 16v-3.73L12 15l5-2.73z"/></svg>
|
||||
</div>
|
||||
<span class="news-badge">Quick Start</span>
|
||||
</div>
|
||||
<div class="news-card-content">
|
||||
<h3>Get Started in 5 Minutes</h3>
|
||||
<p>From installation to your first ECS app, learn the core concepts quickly.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/en/guide/behavior-tree/" class="news-card">
|
||||
<div class="news-card-image" style="background: linear-gradient(135deg, #1e3a5f 0%, #1e1e1e 100%);">
|
||||
<div class="news-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"><path fill="#4ec9b0" d="M12 2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m3 20h-1v-7l-2-2l-2 2v7H9v-7.5l-2 2V22H6v-6l3-3l1-3.5c-.3.4-.6.7-1 1L6 9v1H4V8l5-3c.5-.3 1.1-.5 1.7-.5H11c.6 0 1.2.2 1.7.5l5 3v2h-2V9l-3 1.5c-.4-.3-.7-.6-1-1l1 3.5l3 3v6Z"/></svg>
|
||||
</div>
|
||||
<span class="news-badge">AI System</span>
|
||||
</div>
|
||||
<div class="news-card-content">
|
||||
<h3>Visual Behavior Tree Editor</h3>
|
||||
<p>Built-in AI behavior tree system with visual editing and real-time debugging.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features-section">
|
||||
<div class="features-container">
|
||||
<h2 class="features-title">Core Features</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#4fc1ff" d="M13 2.05v2.02c3.95.49 7 3.85 7 7.93c0 1.45-.39 2.79-1.06 3.95l1.59 1.09A9.94 9.94 0 0 0 22 12c0-5.18-3.95-9.45-9-9.95M12 19c-3.87 0-7-3.13-7-7c0-3.53 2.61-6.43 6-6.92V2.05c-5.06.5-9 4.76-9 9.95c0 5.52 4.47 10 9.99 10c3.31 0 6.24-1.61 8.06-4.09l-1.6-1.1A7.93 7.93 0 0 1 12 19"/><path fill="#4fc1ff" d="M12 6a6 6 0 0 0-6 6c0 3.31 2.69 6 6 6a6 6 0 0 0 0-12m0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4s4 1.79 4 4s-1.79 4-4 4"/></svg>
|
||||
</div>
|
||||
<h3 class="feature-title">High-performance ECS Architecture</h3>
|
||||
<p class="feature-desc">Data-driven entity component system for large-scale entity processing with cache-friendly memory layout.</p>
|
||||
<a href="/en/guide/entity" class="feature-link">Learn more</a>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#569cd6" d="M3 3h18v18H3zm16.525 13.707c0-.795-.272-1.425-.816-1.89c-.544-.465-1.404-.804-2.58-1.016l-1.704-.296c-.616-.104-1.052-.26-1.308-.468c-.256-.21-.384-.468-.384-.776c0-.392.168-.7.504-.924c.336-.224.8-.336 1.392-.336c.56 0 1.008.124 1.344.372c.336.248.536.584.6 1.008h2.016c-.08-.96-.464-1.716-1.152-2.268c-.688-.552-1.6-.828-2.736-.828c-1.2 0-2.148.3-2.844.9c-.696.6-1.044 1.38-1.044 2.34c0 .76.252 1.368.756 1.824c.504.456 1.308.792 2.412.996l1.704.312c.624.12 1.068.28 1.332.48c.264.2.396.46.396.78c0 .424-.192.756-.576.996c-.384.24-.9.36-1.548.36c-.672 0-1.2-.14-1.584-.42c-.384-.28-.608-.668-.672-1.164H8.868c.048 1.016.46 1.808 1.236 2.376c.776.568 1.796.852 3.06.852c1.24 0 2.22-.292 2.94-.876c.72-.584 1.08-1.364 1.08-2.34z"/></svg>
|
||||
</div>
|
||||
<h3 class="feature-title">Full Type Support</h3>
|
||||
<p class="feature-desc">100% TypeScript with complete type definitions and compile-time checking for the best development experience.</p>
|
||||
<a href="/en/guide/component" class="feature-link">Learn more</a>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#4ec9b0" d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10s10-4.5 10-10S17.5 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m-5-8l4-4v3h4v2h-4v3z"/></svg>
|
||||
</div>
|
||||
<h3 class="feature-title">Visual Behavior Tree</h3>
|
||||
<p class="feature-desc">Built-in AI behavior tree system with visual editor, custom nodes, and real-time debugging.</p>
|
||||
<a href="/en/guide/behavior-tree/" class="feature-link">Learn more</a>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#c586c0" d="M4 6h18V4H4c-1.1 0-2 .9-2 2v11H0v3h14v-3H4zm19 2h-6c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h6c.55 0 1-.45 1-1V9c0-.55-.45-1-1-1m-1 9h-4v-7h4z"/></svg>
|
||||
</div>
|
||||
<h3 class="feature-title">Multi-Platform Support</h3>
|
||||
<p class="feature-desc">Support for browsers, Node.js, WeChat Mini Games, and seamless integration with major game engines.</p>
|
||||
<a href="/en/guide/platform-adapter" class="feature-link">Learn more</a>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#dcdcaa" d="M4 3h6v2H4v14h6v2H4c-1.1 0-2-.9-2-2V5c0-1.1.9-2 2-2m9 0h6c1.1 0 2 .9 2 2v14c0 1.1-.9 2-2 2h-6v-2h6V5h-6zm-1 7h4v2h-4z"/></svg>
|
||||
</div>
|
||||
<h3 class="feature-title">Modular Design</h3>
|
||||
<p class="feature-desc">Core features packaged independently, import only what you need. Support for custom plugin extensions.</p>
|
||||
<a href="/en/guide/plugin-system" class="feature-link">Learn more</a>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#9cdcfe" d="M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9c-2-2-5-2.4-7.4-1.3L9 6L6 9L1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4"/></svg>
|
||||
</div>
|
||||
<h3 class="feature-title">Developer Tools</h3>
|
||||
<p class="feature-desc">Built-in performance monitoring, debugging tools, serialization system, and complete development toolchain.</p>
|
||||
<a href="/en/guide/logging" class="feature-link">Learn more</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style scoped>
|
||||
/* Home page specific styles */
|
||||
.news-section {
|
||||
background: #0d0d0d;
|
||||
padding: 64px 0;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.news-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 48px;
|
||||
}
|
||||
|
||||
.news-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.news-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.news-more:hover {
|
||||
background: #252525;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.news-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
display: flex;
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.news-card:hover {
|
||||
border-color: #3b9eff;
|
||||
}
|
||||
|
||||
.news-card-image {
|
||||
width: 200px;
|
||||
min-height: 140px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.news-icon {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.news-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 16px;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.news-card-content {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.news-card-content h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.news-card-content p {
|
||||
font-size: 0.875rem;
|
||||
color: #707070;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.features-section {
|
||||
background: #0d0d0d;
|
||||
padding: 64px 0;
|
||||
}
|
||||
|
||||
.features-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 48px;
|
||||
}
|
||||
|
||||
.features-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin: 0 0 32px 0;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: #3b9eff;
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #0d0d0d;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-size: 14px;
|
||||
color: #707070;
|
||||
line-height: 1.7;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.feature-link {
|
||||
font-size: 14px;
|
||||
color: #3b9eff;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.feature-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.news-container,
|
||||
.features-container {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.news-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.news-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.news-card-image {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -96,20 +96,8 @@ title: ESEngine - 高性能 TypeScript ECS 框架
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* 隐藏 VitePress 默认样式 */
|
||||
.VPDoc {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.vp-doc {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.VPContent {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
<style scoped>
|
||||
/* 首页专用样式 | Home page specific styles */
|
||||
.news-section {
|
||||
background: #0d0d0d;
|
||||
padding: 64px 0;
|
||||
|
||||
@@ -58,7 +58,8 @@
|
||||
"lint": "turbo run lint",
|
||||
"lint:fix": "turbo run lint:fix",
|
||||
"build:wasm": "cd packages/engine && wasm-pack build --dev --out-dir pkg",
|
||||
"build:wasm:release": "cd packages/engine && wasm-pack build --release --out-dir pkg"
|
||||
"build:wasm:release": "cd packages/engine && wasm-pack build --release --out-dir pkg",
|
||||
"copy-modules": "node scripts/copy-engine-modules.mjs"
|
||||
},
|
||||
"author": "yhh",
|
||||
"license": "MIT",
|
||||
@@ -120,3 +121,4 @@
|
||||
"ws": "^8.18.2"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
50
packages/asset-system-editor/package.json
Normal file
50
packages/asset-system-editor/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@esengine/asset-system-editor",
|
||||
"version": "1.0.0",
|
||||
"description": "Editor-side asset management: meta files, packing, and bundling",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:watch": "tsup --watch",
|
||||
"clean": "rimraf dist",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"asset",
|
||||
"editor",
|
||||
"bundle",
|
||||
"packing"
|
||||
],
|
||||
"author": "yhh",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@esengine/asset-system": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"rimraf": "^5.0.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/esengine/ecs-framework.git",
|
||||
"directory": "packages/asset-system-editor"
|
||||
}
|
||||
}
|
||||
39
packages/asset-system-editor/src/index.ts
Normal file
39
packages/asset-system-editor/src/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Asset System Editor
|
||||
* 资产系统编辑器模块
|
||||
*
|
||||
* Editor-side asset management:
|
||||
* - Meta files (.meta) management
|
||||
* - Asset packing and bundling
|
||||
* - Import settings
|
||||
*
|
||||
* 编辑器端资产管理:
|
||||
* - 元数据文件 (.meta) 管理
|
||||
* - 资产打包和捆绑
|
||||
* - 导入设置
|
||||
*/
|
||||
|
||||
// Meta file management
|
||||
export {
|
||||
AssetMetaManager,
|
||||
type IAssetMeta,
|
||||
type IImportSettings,
|
||||
type IMetaFileSystem,
|
||||
generateGUID,
|
||||
getMetaFilePath,
|
||||
inferAssetType,
|
||||
getDefaultImportSettings,
|
||||
createAssetMeta,
|
||||
serializeAssetMeta,
|
||||
parseAssetMeta,
|
||||
isValidGUID
|
||||
} from './meta/AssetMetaFile';
|
||||
|
||||
// Asset packing
|
||||
export {
|
||||
AssetPacker,
|
||||
collectSceneAssets,
|
||||
type IPackingResult,
|
||||
type IPackedBundle,
|
||||
type IAssetFileReader
|
||||
} from './packing/AssetPacker';
|
||||
424
packages/asset-system-editor/src/meta/AssetMetaFile.ts
Normal file
424
packages/asset-system-editor/src/meta/AssetMetaFile.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Asset Meta File (.meta) Management
|
||||
* 资产元数据文件 (.meta) 管理
|
||||
*
|
||||
* Each asset file has a companion .meta file that stores:
|
||||
* - GUID: Persistent unique identifier
|
||||
* - Import settings: How to process the asset
|
||||
* - Labels: User-defined tags
|
||||
*
|
||||
* 每个资产文件都有一个配套的 .meta 文件,存储:
|
||||
* - GUID:持久化唯一标识符
|
||||
* - 导入设置:如何处理资产
|
||||
* - 标签:用户定义的标签
|
||||
*/
|
||||
|
||||
import { AssetGUID, AssetType } from '@esengine/asset-system';
|
||||
|
||||
/**
|
||||
* Meta file content structure
|
||||
* 元数据文件内容结构
|
||||
*/
|
||||
export interface IAssetMeta {
|
||||
/** Persistent unique identifier | 持久化唯一标识符 */
|
||||
guid: AssetGUID;
|
||||
/** Asset type | 资产类型 */
|
||||
type: AssetType;
|
||||
/** Import settings | 导入设置 */
|
||||
importSettings?: IImportSettings;
|
||||
/** User-defined labels | 用户定义的标签 */
|
||||
labels?: string[];
|
||||
/** Meta file version | 元数据文件版本 */
|
||||
version: number;
|
||||
/** Last modified timestamp | 最后修改时间戳 */
|
||||
lastModified?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import settings for different asset types
|
||||
* 不同资产类型的导入设置
|
||||
*/
|
||||
export interface IImportSettings {
|
||||
// Texture settings | 纹理设置
|
||||
maxSize?: number;
|
||||
compression?: 'none' | 'dxt' | 'etc2' | 'astc' | 'webp';
|
||||
generateMipmaps?: boolean;
|
||||
filterMode?: 'point' | 'bilinear' | 'trilinear';
|
||||
wrapMode?: 'clamp' | 'repeat' | 'mirror';
|
||||
premultiplyAlpha?: boolean;
|
||||
|
||||
// Audio settings | 音频设置
|
||||
audioFormat?: 'mp3' | 'ogg' | 'wav';
|
||||
sampleRate?: number;
|
||||
channels?: 1 | 2;
|
||||
normalize?: boolean;
|
||||
|
||||
// General settings | 通用设置
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new UUID v4
|
||||
* 生成新的 UUID v4
|
||||
*/
|
||||
export function generateGUID(): AssetGUID {
|
||||
// Use crypto.randomUUID if available (modern browsers/Node 19+)
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Fallback implementation
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get meta file path for an asset
|
||||
* 获取资产的元数据文件路径
|
||||
*/
|
||||
export function getMetaFilePath(assetPath: string): string {
|
||||
return `${assetPath}.meta`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer asset type from file extension
|
||||
* 根据文件扩展名推断资产类型
|
||||
*/
|
||||
export function inferAssetType(path: string): AssetType {
|
||||
const ext = path.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
const typeMap: Record<string, AssetType> = {
|
||||
// Textures
|
||||
png: 'texture',
|
||||
jpg: 'texture',
|
||||
jpeg: 'texture',
|
||||
gif: 'texture',
|
||||
webp: 'texture',
|
||||
bmp: 'texture',
|
||||
svg: 'texture',
|
||||
|
||||
// Audio
|
||||
mp3: 'audio',
|
||||
wav: 'audio',
|
||||
ogg: 'audio',
|
||||
m4a: 'audio',
|
||||
flac: 'audio',
|
||||
|
||||
// Data
|
||||
json: 'json',
|
||||
txt: 'text',
|
||||
xml: 'text',
|
||||
csv: 'text',
|
||||
|
||||
// Scenes and prefabs
|
||||
ecs: 'scene',
|
||||
prefab: 'prefab',
|
||||
|
||||
// Fonts
|
||||
ttf: 'font',
|
||||
otf: 'font',
|
||||
woff: 'font',
|
||||
woff2: 'font',
|
||||
|
||||
// Shaders
|
||||
glsl: 'shader',
|
||||
vert: 'shader',
|
||||
frag: 'shader',
|
||||
|
||||
// Custom types (plugins)
|
||||
tilemap: 'tilemap',
|
||||
tileset: 'tileset',
|
||||
btree: 'behavior-tree',
|
||||
bp: 'blueprint',
|
||||
mat: 'material'
|
||||
};
|
||||
|
||||
return typeMap[ext] || 'binary';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default import settings for asset type
|
||||
* 获取资产类型的默认导入设置
|
||||
*/
|
||||
export function getDefaultImportSettings(type: AssetType): IImportSettings {
|
||||
switch (type) {
|
||||
case 'texture':
|
||||
return {
|
||||
maxSize: 2048,
|
||||
compression: 'none',
|
||||
generateMipmaps: false,
|
||||
filterMode: 'bilinear',
|
||||
wrapMode: 'clamp',
|
||||
premultiplyAlpha: false
|
||||
};
|
||||
|
||||
case 'audio':
|
||||
return {
|
||||
audioFormat: 'mp3',
|
||||
sampleRate: 44100,
|
||||
channels: 2,
|
||||
normalize: false
|
||||
};
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new meta file content
|
||||
* 创建新的元数据文件内容
|
||||
*/
|
||||
export function createAssetMeta(assetPath: string, overrides?: Partial<IAssetMeta>): IAssetMeta {
|
||||
const type = overrides?.type || inferAssetType(assetPath);
|
||||
|
||||
return {
|
||||
guid: overrides?.guid || generateGUID(),
|
||||
type,
|
||||
importSettings: overrides?.importSettings || getDefaultImportSettings(type),
|
||||
labels: overrides?.labels || [],
|
||||
version: 1,
|
||||
lastModified: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize meta to JSON string
|
||||
* 将元数据序列化为 JSON 字符串
|
||||
*/
|
||||
export function serializeAssetMeta(meta: IAssetMeta): string {
|
||||
return JSON.stringify(meta, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse meta from JSON string
|
||||
* 从 JSON 字符串解析元数据
|
||||
*/
|
||||
export function parseAssetMeta(json: string): IAssetMeta {
|
||||
const meta = JSON.parse(json) as IAssetMeta;
|
||||
|
||||
// Validate required fields
|
||||
if (!meta.guid || typeof meta.guid !== 'string') {
|
||||
throw new Error('Invalid meta file: missing or invalid guid');
|
||||
}
|
||||
if (!meta.type || typeof meta.type !== 'string') {
|
||||
throw new Error('Invalid meta file: missing or invalid type');
|
||||
}
|
||||
|
||||
// Set defaults for optional fields
|
||||
meta.version = meta.version || 1;
|
||||
meta.labels = meta.labels || [];
|
||||
meta.importSettings = meta.importSettings || {};
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate GUID format (UUID v4)
|
||||
* 验证 GUID 格式 (UUID v4)
|
||||
*/
|
||||
export function isValidGUID(guid: string): boolean {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset Meta File Manager
|
||||
* 资产元数据文件管理器
|
||||
*
|
||||
* Handles reading/writing .meta files through a file system interface.
|
||||
*/
|
||||
export class AssetMetaManager {
|
||||
private _cache = new Map<string, IAssetMeta>();
|
||||
private _guidToPath = new Map<AssetGUID, string>();
|
||||
|
||||
/**
|
||||
* File system interface for reading/writing files
|
||||
* 用于读写文件的文件系统接口
|
||||
*/
|
||||
private _fs: IMetaFileSystem | null = null;
|
||||
|
||||
/**
|
||||
* Set file system interface
|
||||
* 设置文件系统接口
|
||||
*/
|
||||
setFileSystem(fs: IMetaFileSystem): void {
|
||||
this._fs = fs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create meta for an asset
|
||||
* 获取或创建资产的元数据
|
||||
*/
|
||||
async getOrCreateMeta(assetPath: string): Promise<IAssetMeta> {
|
||||
// Check cache first
|
||||
const cached = this._cache.get(assetPath);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const metaPath = getMetaFilePath(assetPath);
|
||||
|
||||
// Try to read existing meta file
|
||||
if (this._fs) {
|
||||
try {
|
||||
if (await this._fs.exists(metaPath)) {
|
||||
const content = await this._fs.readText(metaPath);
|
||||
const meta = parseAssetMeta(content);
|
||||
this._cache.set(assetPath, meta);
|
||||
this._guidToPath.set(meta.guid, assetPath);
|
||||
return meta;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to read meta file: ${metaPath}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new meta
|
||||
const meta = createAssetMeta(assetPath);
|
||||
this._cache.set(assetPath, meta);
|
||||
this._guidToPath.set(meta.guid, assetPath);
|
||||
|
||||
// Save to file system
|
||||
if (this._fs) {
|
||||
try {
|
||||
await this._fs.writeText(metaPath, serializeAssetMeta(meta));
|
||||
} catch (e) {
|
||||
console.warn(`Failed to write meta file: ${metaPath}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get meta by GUID
|
||||
* 根据 GUID 获取元数据
|
||||
*/
|
||||
getMetaByGUID(guid: AssetGUID): IAssetMeta | undefined {
|
||||
const path = this._guidToPath.get(guid);
|
||||
return path ? this._cache.get(path) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset path by GUID
|
||||
* 根据 GUID 获取资产路径
|
||||
*/
|
||||
getPathByGUID(guid: AssetGUID): string | undefined {
|
||||
return this._guidToPath.get(guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get GUID by asset path
|
||||
* 根据资产路径获取 GUID
|
||||
*/
|
||||
async getGUIDByPath(assetPath: string): Promise<AssetGUID> {
|
||||
const meta = await this.getOrCreateMeta(assetPath);
|
||||
return meta.guid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update meta and save
|
||||
* 更新元数据并保存
|
||||
*/
|
||||
async updateMeta(assetPath: string, updates: Partial<IAssetMeta>): Promise<void> {
|
||||
const meta = await this.getOrCreateMeta(assetPath);
|
||||
|
||||
// Apply updates
|
||||
Object.assign(meta, updates);
|
||||
meta.lastModified = Date.now();
|
||||
meta.version++;
|
||||
|
||||
// Update cache
|
||||
this._cache.set(assetPath, meta);
|
||||
|
||||
// Handle GUID change (rare, but possible)
|
||||
if (updates.guid && updates.guid !== meta.guid) {
|
||||
this._guidToPath.delete(meta.guid);
|
||||
this._guidToPath.set(updates.guid, assetPath);
|
||||
}
|
||||
|
||||
// Save to file system
|
||||
if (this._fs) {
|
||||
const metaPath = getMetaFilePath(assetPath);
|
||||
await this._fs.writeText(metaPath, serializeAssetMeta(meta));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle asset rename
|
||||
* 处理资产重命名
|
||||
*/
|
||||
async handleAssetRename(oldPath: string, newPath: string): Promise<void> {
|
||||
const meta = this._cache.get(oldPath);
|
||||
if (meta) {
|
||||
// Update cache with new path
|
||||
this._cache.delete(oldPath);
|
||||
this._cache.set(newPath, meta);
|
||||
this._guidToPath.set(meta.guid, newPath);
|
||||
|
||||
// Move meta file
|
||||
if (this._fs) {
|
||||
const oldMetaPath = getMetaFilePath(oldPath);
|
||||
const newMetaPath = getMetaFilePath(newPath);
|
||||
|
||||
if (await this._fs.exists(oldMetaPath)) {
|
||||
const content = await this._fs.readText(oldMetaPath);
|
||||
await this._fs.writeText(newMetaPath, content);
|
||||
await this._fs.delete(oldMetaPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle asset delete
|
||||
* 处理资产删除
|
||||
*/
|
||||
async handleAssetDelete(assetPath: string): Promise<void> {
|
||||
const meta = this._cache.get(assetPath);
|
||||
if (meta) {
|
||||
this._cache.delete(assetPath);
|
||||
this._guidToPath.delete(meta.guid);
|
||||
|
||||
// Delete meta file
|
||||
if (this._fs) {
|
||||
const metaPath = getMetaFilePath(assetPath);
|
||||
if (await this._fs.exists(metaPath)) {
|
||||
await this._fs.delete(metaPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache
|
||||
* 清除缓存
|
||||
*/
|
||||
clear(): void {
|
||||
this._cache.clear();
|
||||
this._guidToPath.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached metas
|
||||
* 获取所有缓存的元数据
|
||||
*/
|
||||
getAllMetas(): Map<string, IAssetMeta> {
|
||||
return new Map(this._cache);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File system interface for meta file operations
|
||||
* 元数据文件操作的文件系统接口
|
||||
*/
|
||||
export interface IMetaFileSystem {
|
||||
exists(path: string): Promise<boolean>;
|
||||
readText(path: string): Promise<string>;
|
||||
writeText(path: string, content: string): Promise<void>;
|
||||
delete(path: string): Promise<void>;
|
||||
}
|
||||
408
packages/asset-system-editor/src/packing/AssetPacker.ts
Normal file
408
packages/asset-system-editor/src/packing/AssetPacker.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Asset Packer
|
||||
* 资产打包器
|
||||
*
|
||||
* Collects and packs assets into bundles for runtime loading.
|
||||
* 收集并将资产打包成运行时加载的包。
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetGUID,
|
||||
AssetType,
|
||||
IBundleManifest,
|
||||
IBundleAssetInfo,
|
||||
IRuntimeCatalog,
|
||||
IRuntimeBundleInfo,
|
||||
IRuntimeAssetLocation,
|
||||
IAssetToPack,
|
||||
IBundlePackOptions
|
||||
} from '@esengine/asset-system';
|
||||
import { IAssetMeta } from '../meta/AssetMetaFile';
|
||||
|
||||
/**
|
||||
* Packing result
|
||||
* 打包结果
|
||||
*/
|
||||
export interface IPackingResult {
|
||||
/** Generated bundles | 生成的包 */
|
||||
bundles: IPackedBundle[];
|
||||
/** Runtime catalog | 运行时目录 */
|
||||
catalog: IRuntimeCatalog;
|
||||
/** Total size in bytes | 总大小 */
|
||||
totalSize: number;
|
||||
/** Number of assets packed | 打包的资产数量 */
|
||||
assetCount: number;
|
||||
/** Packing duration in ms | 打包耗时 */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Packed bundle
|
||||
* 已打包的包
|
||||
*/
|
||||
export interface IPackedBundle {
|
||||
/** Bundle name | 包名称 */
|
||||
name: string;
|
||||
/** Bundle data | 包数据 */
|
||||
data: ArrayBuffer;
|
||||
/** Bundle manifest | 包清单 */
|
||||
manifest: IBundleManifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset file reader interface
|
||||
* 资产文件读取器接口
|
||||
*/
|
||||
export interface IAssetFileReader {
|
||||
readBinary(path: string): Promise<ArrayBuffer>;
|
||||
readText(path: string): Promise<string>;
|
||||
exists(path: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset Packer
|
||||
* 资产打包器
|
||||
*/
|
||||
export class AssetPacker {
|
||||
private _fileReader: IAssetFileReader | null = null;
|
||||
private _assets: IAssetToPack[] = [];
|
||||
private _metas = new Map<AssetGUID, IAssetMeta>();
|
||||
|
||||
/**
|
||||
* Set file reader for loading asset data
|
||||
* 设置用于加载资产数据的文件读取器
|
||||
*/
|
||||
setFileReader(reader: IAssetFileReader): void {
|
||||
this._fileReader = reader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add asset to pack
|
||||
* 添加要打包的资产
|
||||
*/
|
||||
addAsset(asset: IAssetToPack, meta?: IAssetMeta): void {
|
||||
this._assets.push(asset);
|
||||
if (meta) {
|
||||
this._metas.set(asset.guid, meta);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple assets
|
||||
* 添加多个资产
|
||||
*/
|
||||
addAssets(assets: IAssetToPack[]): void {
|
||||
for (const asset of assets) {
|
||||
this.addAsset(asset);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all added assets
|
||||
* 清除所有已添加的资产
|
||||
*/
|
||||
clear(): void {
|
||||
this._assets = [];
|
||||
this._metas.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack assets into bundles
|
||||
* 将资产打包成包
|
||||
*/
|
||||
async pack(options: IBundlePackOptions = { name: 'main' }): Promise<IPackingResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Group assets for bundling
|
||||
const groups = this._groupAssets(options);
|
||||
|
||||
// Pack each group into a bundle
|
||||
const bundles: IPackedBundle[] = [];
|
||||
const catalogAssets: Record<AssetGUID, IRuntimeAssetLocation> = {};
|
||||
const catalogBundles: Record<string, IRuntimeBundleInfo> = {};
|
||||
|
||||
for (const [bundleName, assets] of groups) {
|
||||
const packed = await this._packBundle(bundleName, assets, options);
|
||||
bundles.push(packed);
|
||||
|
||||
// Add to catalog
|
||||
catalogBundles[bundleName] = {
|
||||
url: `assets/${bundleName}.bundle`,
|
||||
size: packed.data.byteLength,
|
||||
hash: await this._hashBuffer(packed.data),
|
||||
preload: bundleName === 'core' || bundleName === 'main'
|
||||
};
|
||||
|
||||
// Add asset locations
|
||||
for (const assetInfo of packed.manifest.assets) {
|
||||
catalogAssets[assetInfo.guid] = {
|
||||
bundle: bundleName,
|
||||
offset: assetInfo.offset,
|
||||
size: assetInfo.size,
|
||||
type: assetInfo.type,
|
||||
name: assetInfo.name
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create catalog
|
||||
const catalog: IRuntimeCatalog = {
|
||||
version: '1.0',
|
||||
createdAt: Date.now(),
|
||||
bundles: catalogBundles,
|
||||
assets: catalogAssets
|
||||
};
|
||||
|
||||
const totalSize = bundles.reduce((sum, b) => sum + b.data.byteLength, 0);
|
||||
|
||||
return {
|
||||
bundles,
|
||||
catalog,
|
||||
totalSize,
|
||||
assetCount: this._assets.length,
|
||||
duration: Date.now() - startTime
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack assets by type (textures.bundle, audio.bundle, etc.)
|
||||
* 按类型打包资产
|
||||
*/
|
||||
async packByType(): Promise<IPackingResult> {
|
||||
return this.pack({
|
||||
name: 'main',
|
||||
groupByType: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Group assets for bundling
|
||||
* 分组资产以便打包
|
||||
*/
|
||||
private _groupAssets(options: IBundlePackOptions): Map<string, IAssetToPack[]> {
|
||||
const groups = new Map<string, IAssetToPack[]>();
|
||||
|
||||
if (options.groupByType) {
|
||||
// Group by asset type
|
||||
for (const asset of this._assets) {
|
||||
const bundleName = this._getBundleNameForType(asset.type);
|
||||
const group = groups.get(bundleName) || [];
|
||||
group.push(asset);
|
||||
groups.set(bundleName, group);
|
||||
}
|
||||
} else {
|
||||
// Single bundle
|
||||
groups.set(options.name, [...this._assets]);
|
||||
}
|
||||
|
||||
// Handle max size splitting
|
||||
if (options.maxSize) {
|
||||
const splitGroups = new Map<string, IAssetToPack[]>();
|
||||
|
||||
for (const [name, assets] of groups) {
|
||||
let currentSize = 0;
|
||||
let partIndex = 0;
|
||||
let currentGroup: IAssetToPack[] = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
const assetSize = asset.data?.byteLength || 0;
|
||||
|
||||
if (currentSize + assetSize > options.maxSize && currentGroup.length > 0) {
|
||||
splitGroups.set(`${name}_${partIndex}`, currentGroup);
|
||||
partIndex++;
|
||||
currentGroup = [];
|
||||
currentSize = 0;
|
||||
}
|
||||
|
||||
currentGroup.push(asset);
|
||||
currentSize += assetSize;
|
||||
}
|
||||
|
||||
if (currentGroup.length > 0) {
|
||||
const finalName = partIndex > 0 ? `${name}_${partIndex}` : name;
|
||||
splitGroups.set(finalName, currentGroup);
|
||||
}
|
||||
}
|
||||
|
||||
return splitGroups;
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bundle name for asset type
|
||||
* 获取资产类型的包名称
|
||||
*/
|
||||
private _getBundleNameForType(type: AssetType): string {
|
||||
const typeGroups: Record<string, string[]> = {
|
||||
textures: ['texture'],
|
||||
audio: ['audio'],
|
||||
data: ['json', 'text', 'binary', 'scene', 'prefab'],
|
||||
fonts: ['font'],
|
||||
shaders: ['shader', 'material'],
|
||||
tilemaps: ['tilemap', 'tileset'],
|
||||
scripts: ['behavior-tree', 'blueprint']
|
||||
};
|
||||
|
||||
for (const [bundleName, types] of Object.entries(typeGroups)) {
|
||||
if (types.includes(type)) {
|
||||
return bundleName;
|
||||
}
|
||||
}
|
||||
|
||||
return 'misc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack a single bundle
|
||||
* 打包单个包
|
||||
*/
|
||||
private async _packBundle(
|
||||
name: string,
|
||||
assets: IAssetToPack[],
|
||||
_options: IBundlePackOptions
|
||||
): Promise<IPackedBundle> {
|
||||
const assetInfos: IBundleAssetInfo[] = [];
|
||||
const dataChunks: ArrayBuffer[] = [];
|
||||
let currentOffset = 0;
|
||||
|
||||
// Load and pack each asset
|
||||
for (const asset of assets) {
|
||||
let data = asset.data;
|
||||
|
||||
// Load data if not provided
|
||||
if (!data && this._fileReader) {
|
||||
try {
|
||||
data = await this._fileReader.readBinary(asset.path);
|
||||
} catch (e) {
|
||||
console.warn(`[AssetPacker] Failed to load asset: ${asset.path}`, e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
console.warn(`[AssetPacker] No data for asset: ${asset.guid}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Align to 4 bytes
|
||||
const padding = (4 - (data.byteLength % 4)) % 4;
|
||||
const paddedSize = data.byteLength + padding;
|
||||
|
||||
assetInfos.push({
|
||||
guid: asset.guid,
|
||||
name: asset.name,
|
||||
type: asset.type,
|
||||
offset: currentOffset,
|
||||
size: data.byteLength
|
||||
});
|
||||
|
||||
// Add data with padding
|
||||
dataChunks.push(data);
|
||||
if (padding > 0) {
|
||||
dataChunks.push(new ArrayBuffer(padding));
|
||||
}
|
||||
|
||||
currentOffset += paddedSize;
|
||||
}
|
||||
|
||||
// Combine all data
|
||||
const totalSize = dataChunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
||||
const bundleData = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
|
||||
for (const chunk of dataChunks) {
|
||||
bundleData.set(new Uint8Array(chunk), offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
|
||||
// Create manifest
|
||||
const manifest: IBundleManifest = {
|
||||
name,
|
||||
version: '1.0',
|
||||
hash: await this._hashBuffer(bundleData.buffer),
|
||||
compression: 'none',
|
||||
size: bundleData.byteLength,
|
||||
assets: assetInfos,
|
||||
dependencies: [],
|
||||
createdAt: Date.now()
|
||||
};
|
||||
|
||||
return {
|
||||
name,
|
||||
data: bundleData.buffer,
|
||||
manifest
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a buffer using SHA-256
|
||||
* 使用 SHA-256 哈希缓冲区
|
||||
*/
|
||||
private async _hashBuffer(buffer: ArrayBuffer): Promise<string> {
|
||||
// Use Web Crypto API if available
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
|
||||
}
|
||||
|
||||
// Fallback: simple hash
|
||||
const view = new Uint8Array(buffer);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < view.length; i++) {
|
||||
hash = ((hash << 5) - hash) + view[i];
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash).toString(16).padStart(16, '0');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect assets referenced by a scene
|
||||
* 收集场景引用的资产
|
||||
*/
|
||||
export async function collectSceneAssets(
|
||||
sceneData: unknown,
|
||||
_metaManager: { getPathByGUID: (guid: AssetGUID) => string | undefined }
|
||||
): Promise<AssetGUID[]> {
|
||||
const guids = new Set<AssetGUID>();
|
||||
|
||||
// Recursively find all GUID references
|
||||
function findGUIDs(obj: unknown): void {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
findGUIDs(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const record = obj as Record<string, unknown>;
|
||||
|
||||
// Check for GUID fields
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (key.endsWith('Guid') || key.endsWith('GUID') || key === 'guid') {
|
||||
if (typeof value === 'string' && isValidGUID(value)) {
|
||||
guids.add(value);
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
findGUIDs(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findGUIDs(sceneData);
|
||||
return Array.from(guids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate GUID format
|
||||
* 验证 GUID 格式
|
||||
*/
|
||||
function isValidGUID(guid: string): boolean {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(guid);
|
||||
}
|
||||
36
packages/asset-system-editor/tsconfig.json
Normal file
36
packages/asset-system-editor/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": false,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
10
packages/asset-system-editor/tsup.config.ts
Normal file
10
packages/asset-system-editor/tsup.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
external: ['@esengine/asset-system']
|
||||
});
|
||||
41
packages/asset-system/module.json
Normal file
41
packages/asset-system/module.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"id": "asset-system",
|
||||
"name": "@esengine/asset-system",
|
||||
"displayName": "Asset System",
|
||||
"description": "Asset loading, caching and management | 资源加载、缓存和管理",
|
||||
"version": "1.0.0",
|
||||
"category": "Core",
|
||||
"icon": "FolderOpen",
|
||||
"tags": [
|
||||
"asset",
|
||||
"resource",
|
||||
"loader"
|
||||
],
|
||||
"isCore": true,
|
||||
"defaultEnabled": true,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": false,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop",
|
||||
"mobile"
|
||||
],
|
||||
"dependencies": [
|
||||
"core"
|
||||
],
|
||||
"exports": {
|
||||
"loaders": [
|
||||
"TextureLoader",
|
||||
"JsonLoader",
|
||||
"TextLoader",
|
||||
"BinaryLoader"
|
||||
],
|
||||
"other": [
|
||||
"AssetManager",
|
||||
"AssetDatabase",
|
||||
"AssetCache"
|
||||
]
|
||||
},
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js"
|
||||
}
|
||||
272
packages/asset-system/src/bundle/BundleFormat.ts
Normal file
272
packages/asset-system/src/bundle/BundleFormat.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Asset Bundle Format Definitions
|
||||
* 资产包格式定义
|
||||
*
|
||||
* Binary format for efficient asset storage and loading.
|
||||
* 用于高效资产存储和加载的二进制格式。
|
||||
*/
|
||||
|
||||
import { AssetGUID, AssetType } from '../types/AssetTypes';
|
||||
|
||||
/**
|
||||
* Bundle file magic number
|
||||
* 包文件魔数
|
||||
*/
|
||||
export const BUNDLE_MAGIC = 'ESBNDL';
|
||||
|
||||
/**
|
||||
* Bundle format version
|
||||
* 包格式版本
|
||||
*/
|
||||
export const BUNDLE_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Bundle compression types
|
||||
* 包压缩类型
|
||||
*/
|
||||
export enum BundleCompression {
|
||||
None = 0,
|
||||
Gzip = 1,
|
||||
Brotli = 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle flags
|
||||
* 包标志
|
||||
*/
|
||||
export enum BundleFlags {
|
||||
None = 0,
|
||||
Compressed = 1 << 0,
|
||||
Encrypted = 1 << 1,
|
||||
Streaming = 1 << 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset type codes for binary serialization
|
||||
* 用于二进制序列化的资产类型代码
|
||||
*/
|
||||
export const AssetTypeCode: Record<string, number> = {
|
||||
texture: 1,
|
||||
audio: 2,
|
||||
json: 3,
|
||||
text: 4,
|
||||
binary: 5,
|
||||
scene: 6,
|
||||
prefab: 7,
|
||||
font: 8,
|
||||
shader: 9,
|
||||
material: 10,
|
||||
mesh: 11,
|
||||
animation: 12,
|
||||
tilemap: 20,
|
||||
tileset: 21,
|
||||
'behavior-tree': 22,
|
||||
blueprint: 23
|
||||
};
|
||||
|
||||
/**
|
||||
* Bundle header structure (32 bytes)
|
||||
* 包头结构 (32 字节)
|
||||
*/
|
||||
export interface IBundleHeader {
|
||||
/** Magic number "ESBNDL" | 魔数 */
|
||||
magic: string;
|
||||
/** Format version | 格式版本 */
|
||||
version: number;
|
||||
/** Bundle flags | 包标志 */
|
||||
flags: BundleFlags;
|
||||
/** Compression type | 压缩类型 */
|
||||
compression: BundleCompression;
|
||||
/** Number of assets | 资产数量 */
|
||||
assetCount: number;
|
||||
/** TOC offset from start | TOC 偏移量 */
|
||||
tocOffset: number;
|
||||
/** Data offset from start | 数据偏移量 */
|
||||
dataOffset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table of Contents entry (40 bytes per entry)
|
||||
* 目录条目 (每条 40 字节)
|
||||
*/
|
||||
export interface IBundleTocEntry {
|
||||
/** Asset GUID (16 bytes as UUID binary) | 资产 GUID */
|
||||
guid: AssetGUID;
|
||||
/** Asset type code | 资产类型代码 */
|
||||
typeCode: number;
|
||||
/** Offset from data section start | 相对于数据段起始的偏移 */
|
||||
offset: number;
|
||||
/** Compressed size in bytes | 压缩后大小 */
|
||||
compressedSize: number;
|
||||
/** Uncompressed size in bytes | 未压缩大小 */
|
||||
uncompressedSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle manifest (JSON sidecar file)
|
||||
* 包清单 (JSON 附属文件)
|
||||
*/
|
||||
export interface IBundleManifest {
|
||||
/** Bundle name | 包名称 */
|
||||
name: string;
|
||||
/** Bundle version | 包版本 */
|
||||
version: string;
|
||||
/** Content hash for integrity | 内容哈希 */
|
||||
hash: string;
|
||||
/** Compression type | 压缩类型 */
|
||||
compression: 'none' | 'gzip' | 'brotli';
|
||||
/** Total bundle size | 包总大小 */
|
||||
size: number;
|
||||
/** Assets in this bundle | 包含的资产 */
|
||||
assets: IBundleAssetInfo[];
|
||||
/** Dependencies on other bundles | 依赖的其他包 */
|
||||
dependencies: string[];
|
||||
/** Creation timestamp | 创建时间戳 */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset info in bundle manifest
|
||||
* 包清单中的资产信息
|
||||
*/
|
||||
export interface IBundleAssetInfo {
|
||||
/** Asset GUID | 资产 GUID */
|
||||
guid: AssetGUID;
|
||||
/** Asset name (for debugging) | 资产名称 (用于调试) */
|
||||
name: string;
|
||||
/** Asset type | 资产类型 */
|
||||
type: AssetType;
|
||||
/** Offset in bundle | 包内偏移 */
|
||||
offset: number;
|
||||
/** Size in bytes | 大小 */
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime catalog format (loaded in browser)
|
||||
* 运行时目录格式 (在浏览器中加载)
|
||||
*/
|
||||
export interface IRuntimeCatalog {
|
||||
/** Catalog version | 目录版本 */
|
||||
version: string;
|
||||
/** Creation timestamp | 创建时间戳 */
|
||||
createdAt: number;
|
||||
/** Available bundles | 可用的包 */
|
||||
bundles: Record<string, IRuntimeBundleInfo>;
|
||||
/** Asset GUID to location mapping | 资产 GUID 到位置的映射 */
|
||||
assets: Record<AssetGUID, IRuntimeAssetLocation>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle info in runtime catalog
|
||||
* 运行时目录中的包信息
|
||||
*/
|
||||
export interface IRuntimeBundleInfo {
|
||||
/** Bundle URL (relative to catalog) | 包 URL */
|
||||
url: string;
|
||||
/** Bundle size in bytes | 包大小 */
|
||||
size: number;
|
||||
/** Content hash | 内容哈希 */
|
||||
hash: string;
|
||||
/** Whether bundle is preloaded | 是否预加载 */
|
||||
preload?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset location in runtime catalog
|
||||
* 运行时目录中的资产位置
|
||||
*/
|
||||
export interface IRuntimeAssetLocation {
|
||||
/** Bundle name containing this asset | 包含此资产的包名 */
|
||||
bundle: string;
|
||||
/** Offset within bundle | 包内偏移 */
|
||||
offset: number;
|
||||
/** Size in bytes | 大小 */
|
||||
size: number;
|
||||
/** Asset type | 资产类型 */
|
||||
type: AssetType;
|
||||
/** Asset name (for debugging) | 资产名称 */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle packing options
|
||||
* 包打包选项
|
||||
*/
|
||||
export interface IBundlePackOptions {
|
||||
/** Bundle name | 包名称 */
|
||||
name: string;
|
||||
/** Compression type | 压缩类型 */
|
||||
compression?: BundleCompression;
|
||||
/** Maximum bundle size (split if exceeded) | 最大包大小 */
|
||||
maxSize?: number;
|
||||
/** Group assets by type | 按类型分组资产 */
|
||||
groupByType?: boolean;
|
||||
/** Include asset names in bundle | 在包中包含资产名称 */
|
||||
includeNames?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset to pack
|
||||
* 要打包的资产
|
||||
*/
|
||||
export interface IAssetToPack {
|
||||
/** Asset GUID | 资产 GUID */
|
||||
guid: AssetGUID;
|
||||
/** Asset path (for reading) | 资产路径 */
|
||||
path: string;
|
||||
/** Asset type | 资产类型 */
|
||||
type: AssetType;
|
||||
/** Asset name | 资产名称 */
|
||||
name: string;
|
||||
/** Raw data (or null to read from path) | 原始数据 */
|
||||
data?: ArrayBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GUID from 16-byte binary
|
||||
* 从 16 字节二进制解析 GUID
|
||||
*/
|
||||
export function parseGUIDFromBinary(bytes: Uint8Array): AssetGUID {
|
||||
const hex = Array.from(bytes)
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize GUID to 16-byte binary
|
||||
* 将 GUID 序列化为 16 字节二进制
|
||||
*/
|
||||
export function serializeGUIDToBinary(guid: AssetGUID): Uint8Array {
|
||||
const hex = guid.replace(/-/g, '');
|
||||
const bytes = new Uint8Array(16);
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get type code from asset type string
|
||||
* 从资产类型字符串获取类型代码
|
||||
*/
|
||||
export function getAssetTypeCode(type: AssetType): number {
|
||||
return AssetTypeCode[type] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset type string from type code
|
||||
* 从类型代码获取资产类型字符串
|
||||
*/
|
||||
export function getAssetTypeFromCode(code: number): AssetType {
|
||||
for (const [type, typeCode] of Object.entries(AssetTypeCode)) {
|
||||
if (typeCode === code) {
|
||||
return type as AssetType;
|
||||
}
|
||||
}
|
||||
return 'binary';
|
||||
}
|
||||
@@ -22,6 +22,76 @@ export class AssetDatabase {
|
||||
private readonly _dependencies = new Map<AssetGUID, Set<AssetGUID>>();
|
||||
private readonly _dependents = new Map<AssetGUID, Set<AssetGUID>>();
|
||||
|
||||
/** Project root path for resolving relative paths. | 项目根路径,用于解析相对路径。 */
|
||||
private _projectRoot: string | null = null;
|
||||
|
||||
/**
|
||||
* Set project root path.
|
||||
* 设置项目根路径。
|
||||
*
|
||||
* @param path - Absolute path to project root. | 项目根目录的绝对路径。
|
||||
*/
|
||||
setProjectRoot(path: string): void {
|
||||
this._projectRoot = path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project root path.
|
||||
* 获取项目根路径。
|
||||
*/
|
||||
getProjectRoot(): string | null {
|
||||
return this._projectRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve relative path to absolute path.
|
||||
* 将相对路径解析为绝对路径。
|
||||
*
|
||||
* @param relativePath - Relative asset path (e.g., "assets/texture.png"). | 相对资产路径。
|
||||
* @returns Absolute file system path. | 绝对文件系统路径。
|
||||
*/
|
||||
resolveAbsolutePath(relativePath: string): string {
|
||||
// Already absolute path (Windows or Unix).
|
||||
// 已经是绝对路径。
|
||||
if (relativePath.match(/^[a-zA-Z]:/) || relativePath.startsWith('/')) {
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
// No project root set, return as-is.
|
||||
// 未设置项目根路径,原样返回。
|
||||
if (!this._projectRoot) {
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
// Join with project root.
|
||||
// 与项目根路径拼接。
|
||||
const separator = this._projectRoot.includes('\\') ? '\\' : '/';
|
||||
const normalizedPath = relativePath.replace(/[/\\]/g, separator);
|
||||
return `${this._projectRoot}${separator}${normalizedPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert absolute path to relative path.
|
||||
* 将绝对路径转换为相对路径。
|
||||
*
|
||||
* @param absolutePath - Absolute file system path. | 绝对文件系统路径。
|
||||
* @returns Relative asset path, or null if not under project root. | 相对资产路径。
|
||||
*/
|
||||
toRelativePath(absolutePath: string): string | null {
|
||||
if (!this._projectRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedAbs = absolutePath.replace(/\\/g, '/');
|
||||
const normalizedRoot = this._projectRoot.replace(/\\/g, '/');
|
||||
|
||||
if (normalizedAbs.startsWith(normalizedRoot)) {
|
||||
return normalizedAbs.substring(normalizedRoot.length + 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add asset to database
|
||||
* 添加资产到数据库
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
IAssetManager,
|
||||
IAssetLoadQueue
|
||||
} from '../interfaces/IAssetManager';
|
||||
import { IAssetLoader, IAssetLoaderFactory } from '../interfaces/IAssetLoader';
|
||||
import { IAssetLoader, IAssetLoaderFactory, IAssetParseContext } from '../interfaces/IAssetLoader';
|
||||
import { IAssetReader, IAssetContent } from '../interfaces/IAssetReader';
|
||||
import { AssetCache } from './AssetCache';
|
||||
import { AssetLoadQueue } from './AssetLoadQueue';
|
||||
import { AssetLoaderFactory } from '../loaders/AssetLoaderFactory';
|
||||
@@ -55,6 +56,9 @@ export class AssetManager implements IAssetManager {
|
||||
private readonly _loaderFactory: IAssetLoaderFactory;
|
||||
private readonly _database: AssetDatabase;
|
||||
|
||||
/** Asset reader for file operations. | 用于文件操作的资产读取器。 */
|
||||
private _reader: IAssetReader | null = null;
|
||||
|
||||
private _nextHandle: AssetHandle = 1;
|
||||
|
||||
private _statistics = {
|
||||
@@ -71,12 +75,35 @@ export class AssetManager implements IAssetManager {
|
||||
this._loaderFactory = new AssetLoaderFactory();
|
||||
this._database = new AssetDatabase();
|
||||
|
||||
// 如果提供了目录,初始化数据库 / Initialize database if catalog provided
|
||||
if (catalog) {
|
||||
this.initializeFromCatalog(catalog);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set asset reader.
|
||||
* 设置资产读取器。
|
||||
*/
|
||||
setReader(reader: IAssetReader): void {
|
||||
this._reader = reader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set project root path for resolving relative paths.
|
||||
* 设置项目根路径用于解析相对路径。
|
||||
*/
|
||||
setProjectRoot(path: string): void {
|
||||
this._database.setProjectRoot(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the asset database.
|
||||
* 获取资产数据库。
|
||||
*/
|
||||
getDatabase(): AssetDatabase {
|
||||
return this._database;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize from catalog
|
||||
* 从目录初始化
|
||||
@@ -196,32 +223,89 @@ export class AssetManager implements IAssetManager {
|
||||
startTime: number,
|
||||
entry: AssetEntry
|
||||
): Promise<IAssetLoadResult<T>> {
|
||||
// 加载依赖 / Load dependencies
|
||||
if (!this._reader) {
|
||||
throw new Error('Asset reader not set. Call setReader() first.');
|
||||
}
|
||||
|
||||
// Load dependencies first.
|
||||
// 先加载依赖。
|
||||
if (metadata.dependencies.length > 0) {
|
||||
await this.loadDependencies(metadata.dependencies, options);
|
||||
}
|
||||
|
||||
// 执行加载 / Execute loading
|
||||
const result = await loader.load(metadata.path, metadata, options);
|
||||
// Resolve absolute path.
|
||||
// 解析绝对路径。
|
||||
const absolutePath = this._database.resolveAbsolutePath(metadata.path);
|
||||
|
||||
// 更新条目 / Update entry
|
||||
entry.asset = result.asset;
|
||||
// Read content based on loader's content type.
|
||||
// 根据加载器的内容类型读取内容。
|
||||
const content = await this.readContent(loader.contentType, absolutePath);
|
||||
|
||||
// Create parse context.
|
||||
// 创建解析上下文。
|
||||
const context: IAssetParseContext = {
|
||||
metadata,
|
||||
options,
|
||||
loadDependency: async <D>(relativePath: string) => {
|
||||
const result = await this.loadAssetByPath<D>(relativePath, options);
|
||||
return result.asset;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse asset.
|
||||
// 解析资产。
|
||||
const asset = await loader.parse(content, context);
|
||||
|
||||
// Update entry.
|
||||
// 更新条目。
|
||||
entry.asset = asset;
|
||||
entry.state = AssetState.Loaded;
|
||||
|
||||
// 缓存资产 / Cache asset
|
||||
this._cache.set(metadata.guid, result.asset);
|
||||
// Cache asset.
|
||||
// 缓存资产。
|
||||
this._cache.set(metadata.guid, asset);
|
||||
|
||||
// 更新统计 / Update statistics
|
||||
// Update statistics.
|
||||
// 更新统计。
|
||||
this._statistics.loadedCount++;
|
||||
|
||||
const loadResult: IAssetLoadResult<T> = {
|
||||
asset: result.asset as T,
|
||||
return {
|
||||
asset: asset as T,
|
||||
handle: entry.handle,
|
||||
metadata,
|
||||
loadTime: performance.now() - startTime
|
||||
};
|
||||
}
|
||||
|
||||
return loadResult;
|
||||
/**
|
||||
* Read content based on content type.
|
||||
* 根据内容类型读取内容。
|
||||
*/
|
||||
private async readContent(contentType: string, absolutePath: string): Promise<IAssetContent> {
|
||||
if (!this._reader) {
|
||||
throw new Error('Asset reader not set');
|
||||
}
|
||||
|
||||
switch (contentType) {
|
||||
case 'text': {
|
||||
const text = await this._reader.readText(absolutePath);
|
||||
return { type: 'text', text };
|
||||
}
|
||||
case 'binary': {
|
||||
const binary = await this._reader.readBinary(absolutePath);
|
||||
return { type: 'binary', binary };
|
||||
}
|
||||
case 'image': {
|
||||
const image = await this._reader.loadImage(absolutePath);
|
||||
return { type: 'image', image };
|
||||
}
|
||||
case 'audio': {
|
||||
const audioBuffer = await this._reader.loadAudio(absolutePath);
|
||||
return { type: 'audio', audioBuffer };
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown content type: ${contentType}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -429,6 +513,19 @@ export class AssetManager implements IAssetManager {
|
||||
return this.getAsset<T>(guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get loaded asset by path (synchronous)
|
||||
* 通过路径获取已加载的资产(同步)
|
||||
*
|
||||
* Returns the asset if it's already loaded, null otherwise.
|
||||
* 如果资产已加载则返回资产,否则返回 null。
|
||||
*/
|
||||
getAssetByPath<T = unknown>(path: string): T | null {
|
||||
const guid = this._pathToGuid.get(path);
|
||||
if (!guid) return null;
|
||||
return this.getAsset<T>(guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if asset is loaded
|
||||
* 检查资产是否已加载
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
/**
|
||||
* Asset System for ECS Framework
|
||||
* ECS框架的资产系统
|
||||
*
|
||||
* Runtime-focused asset management:
|
||||
* - Asset loading and caching
|
||||
* - GUID-based asset resolution
|
||||
* - Bundle loading
|
||||
*
|
||||
* For editor-side functionality (meta files, packing), use @esengine/asset-system-editor
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from './types/AssetTypes';
|
||||
|
||||
// Bundle format (shared types for runtime and editor)
|
||||
export * from './bundle/BundleFormat';
|
||||
|
||||
// Runtime catalog
|
||||
export { RuntimeCatalog, runtimeCatalog } from './runtime/RuntimeCatalog';
|
||||
|
||||
// Interfaces
|
||||
export * from './interfaces/IAssetLoader';
|
||||
export * from './interfaces/IAssetManager';
|
||||
export * from './interfaces/IAssetReader';
|
||||
export * from './interfaces/IResourceComponent';
|
||||
|
||||
// Core
|
||||
@@ -51,9 +65,12 @@ export const assetManager = new AssetManager();
|
||||
* Initialize asset system with catalog
|
||||
* 使用目录初始化资产系统
|
||||
*/
|
||||
export function initializeAssetSystem(catalog?: any): AssetManager {
|
||||
export function initializeAssetSystem(catalog?: IAssetCatalog): AssetManager {
|
||||
if (catalog) {
|
||||
return new AssetManager(catalog);
|
||||
}
|
||||
return assetManager;
|
||||
}
|
||||
|
||||
// Re-export IAssetCatalog for initializeAssetSystem signature
|
||||
import type { IAssetCatalog } from './types/AssetTypes';
|
||||
|
||||
@@ -65,8 +65,8 @@ export class EngineIntegration {
|
||||
* Load texture for component
|
||||
* 为组件加载纹理
|
||||
*
|
||||
* 统一的路径解析入口:相对路径会被转换为 Tauri 可用的 asset:// URL
|
||||
* Unified path resolution entry: relative paths will be converted to Tauri-compatible asset:// URLs
|
||||
* AssetManager 内部会处理路径解析,这里只需传入原始路径。
|
||||
* AssetManager handles path resolution internally, just pass the original path here.
|
||||
*/
|
||||
async loadTextureForComponent(texturePath: string): Promise<number> {
|
||||
// 检查缓存(使用原始路径作为键)
|
||||
@@ -76,19 +76,18 @@ export class EngineIntegration {
|
||||
return existingId;
|
||||
}
|
||||
|
||||
// 使用 globalPathResolver 转换路径
|
||||
// Use globalPathResolver to transform the path
|
||||
const resolvedPath = globalPathResolver.resolve(texturePath);
|
||||
|
||||
// 通过资产系统加载(使用解析后的路径)
|
||||
// Load through asset system (using resolved path)
|
||||
const result = await this._assetManager.loadAssetByPath<ITextureAsset>(resolvedPath);
|
||||
// 通过资产系统加载(AssetManager 内部会解析路径)
|
||||
// Load through asset system (AssetManager resolves path internally)
|
||||
const result = await this._assetManager.loadAssetByPath<ITextureAsset>(texturePath);
|
||||
const textureAsset = result.asset;
|
||||
|
||||
// 如果有引擎桥接,上传到GPU(使用解析后的路径)
|
||||
// Upload to GPU if bridge exists (using resolved path)
|
||||
// 如果有引擎桥接,上传到GPU
|
||||
// Upload to GPU if bridge exists
|
||||
// 使用 globalPathResolver 将路径转换为引擎可用的 URL
|
||||
// Use globalPathResolver to convert path to engine-compatible URL
|
||||
if (this._engineBridge && textureAsset.data) {
|
||||
await this._engineBridge.loadTexture(textureAsset.textureId, resolvedPath);
|
||||
const engineUrl = globalPathResolver.resolve(texturePath);
|
||||
await this._engineBridge.loadTexture(textureAsset.textureId, engineUrl);
|
||||
}
|
||||
|
||||
// 缓存映射(使用原始路径作为键,避免重复解析)
|
||||
|
||||
@@ -7,40 +7,64 @@ import {
|
||||
AssetType,
|
||||
AssetGUID,
|
||||
IAssetLoadOptions,
|
||||
IAssetMetadata,
|
||||
IAssetLoadResult
|
||||
IAssetMetadata
|
||||
} from '../types/AssetTypes';
|
||||
import type { IAssetContent, AssetContentType } from './IAssetReader';
|
||||
|
||||
/**
|
||||
* Base asset loader interface
|
||||
* 基础资产加载器接口
|
||||
* Parse context provided to loaders.
|
||||
* 提供给加载器的解析上下文。
|
||||
*/
|
||||
export interface IAssetParseContext {
|
||||
/** Asset metadata. | 资产元数据。 */
|
||||
metadata: IAssetMetadata;
|
||||
/** Load options. | 加载选项。 */
|
||||
options?: IAssetLoadOptions;
|
||||
/**
|
||||
* Load a dependency asset by relative path.
|
||||
* 通过相对路径加载依赖资产。
|
||||
*/
|
||||
loadDependency<D = unknown>(relativePath: string): Promise<D>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset loader interface.
|
||||
* 资产加载器接口。
|
||||
*
|
||||
* Loaders only parse content, file reading is handled by AssetManager.
|
||||
* 加载器只负责解析内容,文件读取由 AssetManager 处理。
|
||||
*/
|
||||
export interface IAssetLoader<T = unknown> {
|
||||
/** 支持的资产类型 / Supported asset type */
|
||||
/** Supported asset type. | 支持的资产类型。 */
|
||||
readonly supportedType: AssetType;
|
||||
|
||||
/** 支持的文件扩展名 / Supported file extensions */
|
||||
/** Supported file extensions. | 支持的文件扩展名。 */
|
||||
readonly supportedExtensions: string[];
|
||||
|
||||
/**
|
||||
* Load an asset from the given path
|
||||
* 从指定路径加载资产
|
||||
* Required content type for this loader.
|
||||
* 此加载器需要的内容类型。
|
||||
*
|
||||
* - 'text': For JSON, shader, material files
|
||||
* - 'binary': For binary formats
|
||||
* - 'image': For textures
|
||||
* - 'audio': For audio files
|
||||
*/
|
||||
load(
|
||||
path: string,
|
||||
metadata: IAssetMetadata,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<T>>;
|
||||
readonly contentType: AssetContentType;
|
||||
|
||||
/**
|
||||
* Validate if the loader can handle this asset
|
||||
* 验证加载器是否可以处理此资产
|
||||
* Parse asset from content.
|
||||
* 从内容解析资产。
|
||||
*
|
||||
* @param content - File content. | 文件内容。
|
||||
* @param context - Parse context. | 解析上下文。
|
||||
* @returns Parsed asset. | 解析后的资产。
|
||||
*/
|
||||
canLoad(path: string, metadata: IAssetMetadata): boolean;
|
||||
parse(content: IAssetContent, context: IAssetParseContext): Promise<T>;
|
||||
|
||||
/**
|
||||
* Dispose loaded asset and free resources
|
||||
* 释放已加载的资产并释放资源
|
||||
* Dispose loaded asset and free resources.
|
||||
* 释放已加载的资产。
|
||||
*/
|
||||
dispose(asset: T): void;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,12 @@ export interface IAssetManager {
|
||||
*/
|
||||
getAssetByHandle<T = unknown>(handle: AssetHandle): T | null;
|
||||
|
||||
/**
|
||||
* Get loaded asset by path (synchronous)
|
||||
* 通过路径获取已加载的资产(同步)
|
||||
*/
|
||||
getAssetByPath<T = unknown>(path: string): T | null;
|
||||
|
||||
/**
|
||||
* Check if asset is loaded
|
||||
* 检查资产是否已加载
|
||||
|
||||
90
packages/asset-system/src/interfaces/IAssetReader.ts
Normal file
90
packages/asset-system/src/interfaces/IAssetReader.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Asset Reader Interface
|
||||
* 资产读取器接口
|
||||
*
|
||||
* Provides unified file reading abstraction across different platforms.
|
||||
* 提供跨平台的统一文件读取抽象。
|
||||
*/
|
||||
|
||||
/**
|
||||
* Asset content types.
|
||||
* 资产内容类型。
|
||||
*/
|
||||
export type AssetContentType = 'text' | 'binary' | 'image' | 'audio';
|
||||
|
||||
/**
|
||||
* Asset content result.
|
||||
* 资产内容结果。
|
||||
*/
|
||||
export interface IAssetContent {
|
||||
/** Content type. | 内容类型。 */
|
||||
type: AssetContentType;
|
||||
/** Text content (for text/json files). | 文本内容。 */
|
||||
text?: string;
|
||||
/** Binary content. | 二进制内容。 */
|
||||
binary?: ArrayBuffer;
|
||||
/** Image element (for textures). | 图片元素。 */
|
||||
image?: HTMLImageElement;
|
||||
/** Audio buffer (for audio files). | 音频缓冲区。 */
|
||||
audioBuffer?: AudioBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset reader interface.
|
||||
* 资产读取器接口。
|
||||
*
|
||||
* Abstracts platform-specific file reading operations.
|
||||
* 抽象平台特定的文件读取操作。
|
||||
*/
|
||||
export interface IAssetReader {
|
||||
/**
|
||||
* Read file as text.
|
||||
* 读取文件为文本。
|
||||
*
|
||||
* @param absolutePath - Absolute file path. | 绝对文件路径。
|
||||
* @returns Text content. | 文本内容。
|
||||
*/
|
||||
readText(absolutePath: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Read file as binary.
|
||||
* 读取文件为二进制。
|
||||
*
|
||||
* @param absolutePath - Absolute file path. | 绝对文件路径。
|
||||
* @returns Binary content. | 二进制内容。
|
||||
*/
|
||||
readBinary(absolutePath: string): Promise<ArrayBuffer>;
|
||||
|
||||
/**
|
||||
* Load image from file.
|
||||
* 从文件加载图片。
|
||||
*
|
||||
* @param absolutePath - Absolute file path. | 绝对文件路径。
|
||||
* @returns Image element. | 图片元素。
|
||||
*/
|
||||
loadImage(absolutePath: string): Promise<HTMLImageElement>;
|
||||
|
||||
/**
|
||||
* Load audio from file.
|
||||
* 从文件加载音频。
|
||||
*
|
||||
* @param absolutePath - Absolute file path. | 绝对文件路径。
|
||||
* @returns Audio buffer. | 音频缓冲区。
|
||||
*/
|
||||
loadAudio(absolutePath: string): Promise<AudioBuffer>;
|
||||
|
||||
/**
|
||||
* Check if file exists.
|
||||
* 检查文件是否存在。
|
||||
*
|
||||
* @param absolutePath - Absolute file path. | 绝对文件路径。
|
||||
* @returns True if exists. | 是否存在。
|
||||
*/
|
||||
exists(absolutePath: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service identifier for IAssetReader.
|
||||
* IAssetReader 的服务标识符。
|
||||
*/
|
||||
export const IAssetReaderService = Symbol.for('IAssetReaderService');
|
||||
@@ -3,14 +3,9 @@
|
||||
* 二进制资产加载器
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetType,
|
||||
IAssetLoadOptions,
|
||||
IAssetMetadata,
|
||||
IAssetLoadResult,
|
||||
AssetLoadError
|
||||
} from '../types/AssetTypes';
|
||||
import { IAssetLoader, IBinaryAsset } from '../interfaces/IAssetLoader';
|
||||
import { AssetType } from '../types/AssetTypes';
|
||||
import { IAssetLoader, IBinaryAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
|
||||
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
|
||||
|
||||
/**
|
||||
* Binary loader implementation
|
||||
@@ -22,144 +17,27 @@ export class BinaryLoader implements IAssetLoader<IBinaryAsset> {
|
||||
'.bin', '.dat', '.raw', '.bytes',
|
||||
'.wasm', '.so', '.dll', '.dylib'
|
||||
];
|
||||
readonly contentType: AssetContentType = 'binary';
|
||||
|
||||
/**
|
||||
* Load binary asset
|
||||
* 加载二进制资产
|
||||
* Parse binary from content.
|
||||
* 从内容解析二进制。
|
||||
*/
|
||||
async load(
|
||||
path: string,
|
||||
metadata: IAssetMetadata,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<IBinaryAsset>> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(path, options?.timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// 获取MIME类型 / Get MIME type
|
||||
const mimeType = response.headers.get('content-type') || undefined;
|
||||
|
||||
// 获取总大小用于进度回调 / Get total size for progress callback
|
||||
const contentLength = response.headers.get('content-length');
|
||||
const total = contentLength ? parseInt(contentLength, 10) : 0;
|
||||
|
||||
// 读取响应 / Read response
|
||||
let data: ArrayBuffer;
|
||||
if (options?.onProgress && total > 0) {
|
||||
data = await this.readResponseWithProgress(response, total, options.onProgress);
|
||||
} else {
|
||||
data = await response.arrayBuffer();
|
||||
}
|
||||
|
||||
const asset: IBinaryAsset = {
|
||||
data,
|
||||
mimeType
|
||||
};
|
||||
|
||||
return {
|
||||
asset,
|
||||
handle: 0,
|
||||
metadata,
|
||||
loadTime: performance.now() - startTime
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new AssetLoadError(
|
||||
`Failed to load binary: ${error.message}`,
|
||||
metadata.guid,
|
||||
AssetType.Binary,
|
||||
error
|
||||
);
|
||||
}
|
||||
throw AssetLoadError.fileNotFound(metadata.guid, path);
|
||||
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<IBinaryAsset> {
|
||||
if (!content.binary) {
|
||||
throw new Error('Binary content is empty');
|
||||
}
|
||||
|
||||
return {
|
||||
data: content.binary
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with timeout
|
||||
* 带超时的fetch
|
||||
*/
|
||||
private async fetchWithTimeout(url: string, timeout = 30000): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read response with progress
|
||||
* 带进度读取响应
|
||||
*/
|
||||
private async readResponseWithProgress(
|
||||
response: Response,
|
||||
total: number,
|
||||
onProgress: (progress: number) => void
|
||||
): Promise<ArrayBuffer> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let receivedLength = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
chunks.push(value);
|
||||
receivedLength += value.length;
|
||||
|
||||
// 报告进度 / Report progress
|
||||
onProgress(receivedLength / total);
|
||||
}
|
||||
|
||||
// 合并chunks到ArrayBuffer / Merge chunks into ArrayBuffer
|
||||
const result = new Uint8Array(receivedLength);
|
||||
let position = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, position);
|
||||
position += chunk.length;
|
||||
}
|
||||
|
||||
return result.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if the loader can handle this asset
|
||||
* 验证加载器是否可以处理此资产
|
||||
*/
|
||||
canLoad(path: string, _metadata: IAssetMetadata): boolean {
|
||||
const ext = path.toLowerCase().substring(path.lastIndexOf('.'));
|
||||
return this.supportedExtensions.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate memory usage for the asset
|
||||
* 估算资产的内存使用量
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dispose loaded asset
|
||||
* 释放已加载的资产
|
||||
*/
|
||||
dispose(asset: IBinaryAsset): void {
|
||||
// ArrayBuffer无法直接释放,但可以清空引用 / Can't directly release ArrayBuffer, but clear reference
|
||||
(asset as any).data = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,9 @@
|
||||
* JSON资产加载器
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetType,
|
||||
IAssetLoadOptions,
|
||||
IAssetMetadata,
|
||||
IAssetLoadResult,
|
||||
AssetLoadError
|
||||
} from '../types/AssetTypes';
|
||||
import { IAssetLoader, IJsonAsset } from '../interfaces/IAssetLoader';
|
||||
import { AssetType } from '../types/AssetTypes';
|
||||
import { IAssetLoader, IJsonAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
|
||||
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
|
||||
|
||||
/**
|
||||
* JSON loader implementation
|
||||
@@ -19,144 +14,27 @@ import { IAssetLoader, IJsonAsset } from '../interfaces/IAssetLoader';
|
||||
export class JsonLoader implements IAssetLoader<IJsonAsset> {
|
||||
readonly supportedType = AssetType.Json;
|
||||
readonly supportedExtensions = ['.json', '.jsonc'];
|
||||
readonly contentType: AssetContentType = 'text';
|
||||
|
||||
/**
|
||||
* Load JSON asset
|
||||
* 加载JSON资产
|
||||
* Parse JSON from text content.
|
||||
* 从文本内容解析JSON。
|
||||
*/
|
||||
async load(
|
||||
path: string,
|
||||
metadata: IAssetMetadata,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<IJsonAsset>> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(path, options?.timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// 获取总大小用于进度回调 / Get total size for progress callback
|
||||
const contentLength = response.headers.get('content-length');
|
||||
const total = contentLength ? parseInt(contentLength, 10) : 0;
|
||||
|
||||
// 读取响应 / Read response
|
||||
let jsonData: unknown;
|
||||
if (options?.onProgress && total > 0) {
|
||||
jsonData = await this.readResponseWithProgress(response, total, options.onProgress);
|
||||
} else {
|
||||
jsonData = await response.json();
|
||||
}
|
||||
|
||||
const asset: IJsonAsset = {
|
||||
data: jsonData
|
||||
};
|
||||
|
||||
return {
|
||||
asset,
|
||||
handle: 0,
|
||||
metadata,
|
||||
loadTime: performance.now() - startTime
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new AssetLoadError(
|
||||
`Failed to load JSON: ${error.message}`,
|
||||
metadata.guid,
|
||||
AssetType.Json,
|
||||
error
|
||||
);
|
||||
}
|
||||
throw AssetLoadError.fileNotFound(metadata.guid, path);
|
||||
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<IJsonAsset> {
|
||||
if (!content.text) {
|
||||
throw new Error('JSON content is empty');
|
||||
}
|
||||
|
||||
return {
|
||||
data: JSON.parse(content.text)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with timeout
|
||||
* 带超时的fetch
|
||||
*/
|
||||
private async fetchWithTimeout(url: string, timeout = 30000): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read response with progress
|
||||
* 带进度读取响应
|
||||
*/
|
||||
private async readResponseWithProgress(
|
||||
response: Response,
|
||||
total: number,
|
||||
onProgress: (progress: number) => void
|
||||
): Promise<unknown> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let receivedLength = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
chunks.push(value);
|
||||
receivedLength += value.length;
|
||||
|
||||
// 报告进度 / Report progress
|
||||
onProgress(receivedLength / total);
|
||||
}
|
||||
|
||||
// 合并chunks / Merge chunks
|
||||
const allChunks = new Uint8Array(receivedLength);
|
||||
let position = 0;
|
||||
for (const chunk of chunks) {
|
||||
allChunks.set(chunk, position);
|
||||
position += chunk.length;
|
||||
}
|
||||
|
||||
// 解码为字符串并解析JSON / Decode to string and parse JSON
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const jsonString = decoder.decode(allChunks);
|
||||
return JSON.parse(jsonString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if the loader can handle this asset
|
||||
* 验证加载器是否可以处理此资产
|
||||
*/
|
||||
canLoad(path: string, _metadata: IAssetMetadata): boolean {
|
||||
const ext = path.toLowerCase().substring(path.lastIndexOf('.'));
|
||||
return this.supportedExtensions.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate memory usage for the asset
|
||||
* 估算资产的内存使用量
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dispose loaded asset
|
||||
* 释放已加载的资产
|
||||
*/
|
||||
dispose(asset: IJsonAsset): void {
|
||||
// JSON资产通常不需要特殊清理 / JSON assets usually don't need special cleanup
|
||||
// 但可以清空引用以帮助GC / But can clear references to help GC
|
||||
(asset as any).data = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,9 @@
|
||||
* 文本资产加载器
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetType,
|
||||
IAssetLoadOptions,
|
||||
IAssetMetadata,
|
||||
IAssetLoadResult,
|
||||
AssetLoadError
|
||||
} from '../types/AssetTypes';
|
||||
import { IAssetLoader, ITextAsset } from '../interfaces/IAssetLoader';
|
||||
import { AssetType } from '../types/AssetTypes';
|
||||
import { IAssetLoader, ITextAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
|
||||
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
|
||||
|
||||
/**
|
||||
* Text loader implementation
|
||||
@@ -19,115 +14,21 @@ import { IAssetLoader, ITextAsset } from '../interfaces/IAssetLoader';
|
||||
export class TextLoader implements IAssetLoader<ITextAsset> {
|
||||
readonly supportedType = AssetType.Text;
|
||||
readonly supportedExtensions = ['.txt', '.text', '.md', '.csv', '.xml', '.html', '.css', '.js', '.ts'];
|
||||
readonly contentType: AssetContentType = 'text';
|
||||
|
||||
/**
|
||||
* Load text asset
|
||||
* 加载文本资产
|
||||
* Parse text from content.
|
||||
* 从内容解析文本。
|
||||
*/
|
||||
async load(
|
||||
path: string,
|
||||
metadata: IAssetMetadata,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<ITextAsset>> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(path, options?.timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// 获取总大小用于进度回调 / Get total size for progress callback
|
||||
const contentLength = response.headers.get('content-length');
|
||||
const total = contentLength ? parseInt(contentLength, 10) : 0;
|
||||
|
||||
// 读取响应 / Read response
|
||||
let content: string;
|
||||
if (options?.onProgress && total > 0) {
|
||||
content = await this.readResponseWithProgress(response, total, options.onProgress);
|
||||
} else {
|
||||
content = await response.text();
|
||||
}
|
||||
|
||||
// 检测编码 / Detect encoding
|
||||
const encoding = this.detectEncoding(content);
|
||||
|
||||
const asset: ITextAsset = {
|
||||
content,
|
||||
encoding
|
||||
};
|
||||
|
||||
return {
|
||||
asset,
|
||||
handle: 0,
|
||||
metadata,
|
||||
loadTime: performance.now() - startTime
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new AssetLoadError(
|
||||
`Failed to load text: ${error.message}`,
|
||||
metadata.guid,
|
||||
AssetType.Text,
|
||||
error
|
||||
);
|
||||
}
|
||||
throw AssetLoadError.fileNotFound(metadata.guid, path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with timeout
|
||||
* 带超时的fetch
|
||||
*/
|
||||
private async fetchWithTimeout(url: string, timeout = 30000): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read response with progress
|
||||
* 带进度读取响应
|
||||
*/
|
||||
private async readResponseWithProgress(
|
||||
response: Response,
|
||||
total: number,
|
||||
onProgress: (progress: number) => void
|
||||
): Promise<string> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return response.text();
|
||||
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<ITextAsset> {
|
||||
if (!content.text) {
|
||||
throw new Error('Text content is empty');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let result = '';
|
||||
let receivedLength = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
receivedLength += value.length;
|
||||
result += decoder.decode(value, { stream: true });
|
||||
|
||||
// 报告进度 / Report progress
|
||||
onProgress(receivedLength / total);
|
||||
}
|
||||
|
||||
return result;
|
||||
return {
|
||||
content: content.text,
|
||||
encoding: this.detectEncoding(content.text)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,38 +36,20 @@ export class TextLoader implements IAssetLoader<ITextAsset> {
|
||||
* 检测文本编码
|
||||
*/
|
||||
private detectEncoding(content: string): 'utf8' | 'utf16' | 'ascii' {
|
||||
// 简单的编码检测 / Simple encoding detection
|
||||
// 检查是否包含非ASCII字符 / Check for non-ASCII characters
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const charCode = content.charCodeAt(i);
|
||||
if (charCode > 127) {
|
||||
// 包含非ASCII字符,可能是UTF-8或UTF-16 / Contains non-ASCII, likely UTF-8 or UTF-16
|
||||
return charCode > 255 ? 'utf16' : 'utf8';
|
||||
}
|
||||
}
|
||||
return 'ascii';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if the loader can handle this asset
|
||||
* 验证加载器是否可以处理此资产
|
||||
*/
|
||||
canLoad(path: string, _metadata: IAssetMetadata): boolean {
|
||||
const ext = path.toLowerCase().substring(path.lastIndexOf('.'));
|
||||
return this.supportedExtensions.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate memory usage for the asset
|
||||
* 估算资产的内存使用量
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dispose loaded asset
|
||||
* 释放已加载的资产
|
||||
*/
|
||||
dispose(asset: ITextAsset): void {
|
||||
// 清空内容以帮助GC / Clear content to help GC
|
||||
(asset as any).content = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,9 @@
|
||||
* 纹理资产加载器
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetType,
|
||||
IAssetLoadOptions,
|
||||
IAssetMetadata,
|
||||
IAssetLoadResult,
|
||||
AssetLoadError
|
||||
} from '../types/AssetTypes';
|
||||
import { IAssetLoader, ITextureAsset } from '../interfaces/IAssetLoader';
|
||||
import { AssetType } from '../types/AssetTypes';
|
||||
import { IAssetLoader, ITextureAsset, IAssetParseContext } from '../interfaces/IAssetLoader';
|
||||
import { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
|
||||
|
||||
/**
|
||||
* Texture loader implementation
|
||||
@@ -19,147 +14,36 @@ import { IAssetLoader, ITextureAsset } from '../interfaces/IAssetLoader';
|
||||
export class TextureLoader implements IAssetLoader<ITextureAsset> {
|
||||
readonly supportedType = AssetType.Texture;
|
||||
readonly supportedExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'];
|
||||
readonly contentType: AssetContentType = 'image';
|
||||
|
||||
private static _nextTextureId = 1;
|
||||
private readonly _loadedTextures = new Map<string, ITextureAsset>();
|
||||
|
||||
/**
|
||||
* Load texture asset
|
||||
* 加载纹理资产
|
||||
* Parse texture from image content.
|
||||
* 从图片内容解析纹理。
|
||||
*/
|
||||
async load(
|
||||
path: string,
|
||||
metadata: IAssetMetadata,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<ITextureAsset>> {
|
||||
const startTime = performance.now();
|
||||
|
||||
// 检查缓存 / Check cache
|
||||
if (!options?.forceReload && this._loadedTextures.has(path)) {
|
||||
const cached = this._loadedTextures.get(path)!;
|
||||
return {
|
||||
asset: cached,
|
||||
handle: cached.textureId,
|
||||
metadata,
|
||||
loadTime: 0
|
||||
};
|
||||
async parse(content: IAssetContent, context: IAssetParseContext): Promise<ITextureAsset> {
|
||||
if (!content.image) {
|
||||
throw new Error('Texture content is empty');
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建图像元素 / Create image element
|
||||
const image = await this.loadImage(path, options);
|
||||
const image = content.image;
|
||||
|
||||
// 创建纹理资产 / Create texture asset
|
||||
const textureAsset: ITextureAsset = {
|
||||
textureId: TextureLoader._nextTextureId++,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
format: 'rgba', // 默认格式 / Default format
|
||||
hasMipmaps: false,
|
||||
data: image
|
||||
};
|
||||
const textureAsset: ITextureAsset = {
|
||||
textureId: TextureLoader._nextTextureId++,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
format: 'rgba',
|
||||
hasMipmaps: false,
|
||||
data: image
|
||||
};
|
||||
|
||||
// 缓存纹理 / Cache texture
|
||||
this._loadedTextures.set(path, textureAsset);
|
||||
|
||||
// 触发引擎纹理加载(如果有引擎桥接) / Trigger engine texture loading if bridge exists
|
||||
if (typeof window !== 'undefined' && (window as any).engineBridge) {
|
||||
await this.uploadToGPU(textureAsset, path);
|
||||
}
|
||||
|
||||
return {
|
||||
asset: textureAsset,
|
||||
handle: textureAsset.textureId,
|
||||
metadata,
|
||||
loadTime: performance.now() - startTime
|
||||
};
|
||||
} catch (error) {
|
||||
throw AssetLoadError.fileNotFound(metadata.guid, path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load image from URL
|
||||
* 从URL加载图像
|
||||
*/
|
||||
private async loadImage(url: string, options?: IAssetLoadOptions): Promise<HTMLImageElement> {
|
||||
// For Tauri asset URLs, use fetch to load the image
|
||||
// 对于Tauri资产URL,使用fetch加载图像
|
||||
if (url.startsWith('http://asset.localhost/') || url.startsWith('asset://')) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.statusText}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
// Clean up blob URL after loading
|
||||
// 加载后清理blob URL
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
reject(new Error(`Failed to load image from blob: ${url}`));
|
||||
};
|
||||
image.src = blobUrl;
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load Tauri asset: ${url} - ${error}`);
|
||||
}
|
||||
// Upload to GPU if bridge exists.
|
||||
if (typeof window !== 'undefined' && (window as any).engineBridge) {
|
||||
await this.uploadToGPU(textureAsset, context.metadata.path);
|
||||
}
|
||||
|
||||
// For regular URLs, use standard Image loading
|
||||
// 对于常规URL,使用标准Image加载
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
|
||||
// 超时处理 / Timeout handling
|
||||
const timeout = options?.timeout || 30000;
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error(`Image load timeout: ${url}`));
|
||||
}, timeout);
|
||||
|
||||
// 进度回调 / Progress callback
|
||||
if (options?.onProgress) {
|
||||
// 图像加载没有真正的进度事件,模拟进度 / Images don't have real progress events, simulate
|
||||
let progress = 0;
|
||||
const progressInterval = setInterval(() => {
|
||||
progress = Math.min(progress + 0.1, 0.9);
|
||||
options.onProgress!(progress);
|
||||
}, 100);
|
||||
|
||||
image.onload = () => {
|
||||
clearInterval(progressInterval);
|
||||
clearTimeout(timeoutId);
|
||||
options.onProgress!(1);
|
||||
resolve(image);
|
||||
};
|
||||
|
||||
image.onerror = () => {
|
||||
clearInterval(progressInterval);
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error(`Failed to load image: ${url}`));
|
||||
};
|
||||
} else {
|
||||
image.onload = () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(image);
|
||||
};
|
||||
|
||||
image.onerror = () => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error(`Failed to load image: ${url}`));
|
||||
};
|
||||
}
|
||||
|
||||
image.src = url;
|
||||
});
|
||||
return textureAsset;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,34 +57,12 @@ export class TextureLoader implements IAssetLoader<ITextureAsset> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if the loader can handle this asset
|
||||
* 验证加载器是否可以处理此资产
|
||||
*/
|
||||
canLoad(path: string, _metadata: IAssetMetadata): boolean {
|
||||
const ext = path.toLowerCase().substring(path.lastIndexOf('.'));
|
||||
return this.supportedExtensions.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate memory usage for the asset
|
||||
* 估算资产的内存使用量
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dispose loaded asset
|
||||
* 释放已加载的资产
|
||||
*/
|
||||
dispose(asset: ITextureAsset): void {
|
||||
// 从缓存中移除 / Remove from cache
|
||||
for (const [path, cached] of this._loadedTextures.entries()) {
|
||||
if (cached === asset) {
|
||||
this._loadedTextures.delete(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 释放GPU资源 / Release GPU resources
|
||||
// Release GPU resources.
|
||||
if (typeof window !== 'undefined' && (window as any).engineBridge) {
|
||||
const bridge = (window as any).engineBridge;
|
||||
if (bridge.unloadTexture) {
|
||||
@@ -208,7 +70,7 @@ export class TextureLoader implements IAssetLoader<ITextureAsset> {
|
||||
}
|
||||
}
|
||||
|
||||
// 清理图像数据 / Clean up image data
|
||||
// Clean up image data.
|
||||
if (asset.data instanceof HTMLImageElement) {
|
||||
asset.data.src = '';
|
||||
}
|
||||
|
||||
275
packages/asset-system/src/runtime/RuntimeCatalog.ts
Normal file
275
packages/asset-system/src/runtime/RuntimeCatalog.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Runtime Catalog for Asset Resolution
|
||||
* 资产解析的运行时目录
|
||||
*
|
||||
* Provides GUID-based asset lookup at runtime.
|
||||
* 提供运行时基于 GUID 的资产查找。
|
||||
*/
|
||||
|
||||
import { AssetGUID, AssetType } from '../types/AssetTypes';
|
||||
import {
|
||||
IRuntimeCatalog,
|
||||
IRuntimeAssetLocation,
|
||||
IRuntimeBundleInfo
|
||||
} from '../bundle/BundleFormat';
|
||||
|
||||
/**
|
||||
* Runtime Catalog Manager
|
||||
* 运行时目录管理器
|
||||
*
|
||||
* Loads and manages the asset catalog for runtime GUID resolution.
|
||||
*/
|
||||
export class RuntimeCatalog {
|
||||
private _catalog: IRuntimeCatalog | null = null;
|
||||
private _loadedBundles = new Map<string, ArrayBuffer>();
|
||||
private _loadingBundles = new Map<string, Promise<ArrayBuffer>>();
|
||||
private _baseUrl: string = './';
|
||||
|
||||
/**
|
||||
* Set base URL for loading catalog and bundles
|
||||
* 设置加载目录和包的基础 URL
|
||||
*/
|
||||
setBaseUrl(url: string): void {
|
||||
this._baseUrl = url.endsWith('/') ? url : `${url}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load catalog from URL
|
||||
* 从 URL 加载目录
|
||||
*/
|
||||
async loadCatalog(catalogUrl?: string): Promise<void> {
|
||||
const url = catalogUrl || `${this._baseUrl}asset-catalog.json`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load catalog: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this._catalog = this._parseCatalog(data);
|
||||
|
||||
console.log(`[RuntimeCatalog] Loaded catalog with ${Object.keys(this._catalog.assets).length} assets`);
|
||||
} catch (error) {
|
||||
console.error('[RuntimeCatalog] Failed to load catalog:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize with pre-loaded catalog data
|
||||
* 使用预加载的目录数据初始化
|
||||
*/
|
||||
initWithData(catalogData: IRuntimeCatalog): void {
|
||||
this._catalog = catalogData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if catalog is loaded
|
||||
* 检查目录是否已加载
|
||||
*/
|
||||
isLoaded(): boolean {
|
||||
return this._catalog !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset location by GUID
|
||||
* 根据 GUID 获取资产位置
|
||||
*/
|
||||
getAssetLocation(guid: AssetGUID): IRuntimeAssetLocation | null {
|
||||
if (!this._catalog) {
|
||||
console.warn('[RuntimeCatalog] Catalog not loaded');
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._catalog.assets[guid] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if asset exists in catalog
|
||||
* 检查资产是否存在于目录中
|
||||
*/
|
||||
hasAsset(guid: AssetGUID): boolean {
|
||||
return this._catalog?.assets[guid] !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all assets of a specific type
|
||||
* 获取特定类型的所有资产
|
||||
*/
|
||||
getAssetsByType(type: AssetType): AssetGUID[] {
|
||||
if (!this._catalog) return [];
|
||||
|
||||
return Object.entries(this._catalog.assets)
|
||||
.filter(([_, loc]) => loc.type === type)
|
||||
.map(([guid]) => guid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bundle info
|
||||
* 获取包信息
|
||||
*/
|
||||
getBundleInfo(bundleName: string): IRuntimeBundleInfo | null {
|
||||
return this._catalog?.bundles[bundleName] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a bundle
|
||||
* 加载包
|
||||
*/
|
||||
async loadBundle(bundleName: string): Promise<ArrayBuffer> {
|
||||
// Return cached bundle
|
||||
const cached = this._loadedBundles.get(bundleName);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Return pending load
|
||||
const pending = this._loadingBundles.get(bundleName);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
// Start new load
|
||||
const bundleInfo = this.getBundleInfo(bundleName);
|
||||
if (!bundleInfo) {
|
||||
throw new Error(`Bundle not found in catalog: ${bundleName}`);
|
||||
}
|
||||
|
||||
const loadPromise = this._fetchBundle(bundleInfo);
|
||||
this._loadingBundles.set(bundleName, loadPromise);
|
||||
|
||||
try {
|
||||
const data = await loadPromise;
|
||||
this._loadedBundles.set(bundleName, data);
|
||||
return data;
|
||||
} finally {
|
||||
this._loadingBundles.delete(bundleName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load asset data by GUID
|
||||
* 根据 GUID 加载资产数据
|
||||
*/
|
||||
async loadAssetData(guid: AssetGUID): Promise<ArrayBuffer> {
|
||||
const location = this.getAssetLocation(guid);
|
||||
if (!location) {
|
||||
throw new Error(`Asset not found in catalog: ${guid}`);
|
||||
}
|
||||
|
||||
// Load the bundle containing this asset
|
||||
const bundleData = await this.loadBundle(location.bundle);
|
||||
|
||||
// Extract asset data from bundle
|
||||
return bundleData.slice(location.offset, location.offset + location.size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload bundles marked for preloading
|
||||
* 预加载标记为预加载的包
|
||||
*/
|
||||
async preloadBundles(): Promise<void> {
|
||||
if (!this._catalog) return;
|
||||
|
||||
const preloadPromises: Promise<void>[] = [];
|
||||
|
||||
for (const [name, info] of Object.entries(this._catalog.bundles)) {
|
||||
if (info.preload) {
|
||||
preloadPromises.push(
|
||||
this.loadBundle(name).then(() => {
|
||||
console.log(`[RuntimeCatalog] Preloaded bundle: ${name}`);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(preloadPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload a bundle from memory
|
||||
* 从内存卸载包
|
||||
*/
|
||||
unloadBundle(bundleName: string): void {
|
||||
this._loadedBundles.delete(bundleName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all loaded bundles
|
||||
* 清除所有已加载的包
|
||||
*/
|
||||
clearBundles(): void {
|
||||
this._loadedBundles.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get catalog statistics
|
||||
* 获取目录统计信息
|
||||
*/
|
||||
getStatistics(): {
|
||||
totalAssets: number;
|
||||
totalBundles: number;
|
||||
loadedBundles: number;
|
||||
assetsByType: Record<string, number>;
|
||||
} {
|
||||
if (!this._catalog) {
|
||||
return {
|
||||
totalAssets: 0,
|
||||
totalBundles: 0,
|
||||
loadedBundles: 0,
|
||||
assetsByType: {}
|
||||
};
|
||||
}
|
||||
|
||||
const assetsByType: Record<string, number> = {};
|
||||
for (const loc of Object.values(this._catalog.assets)) {
|
||||
assetsByType[loc.type] = (assetsByType[loc.type] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
totalAssets: Object.keys(this._catalog.assets).length,
|
||||
totalBundles: Object.keys(this._catalog.bundles).length,
|
||||
loadedBundles: this._loadedBundles.size,
|
||||
assetsByType
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse catalog JSON to typed structure
|
||||
* 将目录 JSON 解析为类型化结构
|
||||
*/
|
||||
private _parseCatalog(data: unknown): IRuntimeCatalog {
|
||||
const raw = data as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
version: (raw.version as string) || '1.0',
|
||||
createdAt: (raw.createdAt as number) || Date.now(),
|
||||
bundles: (raw.bundles as Record<string, IRuntimeBundleInfo>) || {},
|
||||
assets: (raw.assets as Record<AssetGUID, IRuntimeAssetLocation>) || {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch bundle data
|
||||
* 获取包数据
|
||||
*/
|
||||
private async _fetchBundle(info: IRuntimeBundleInfo): Promise<ArrayBuffer> {
|
||||
const url = info.url.startsWith('http')
|
||||
? info.url
|
||||
: `${this._baseUrl}${info.url}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load bundle: ${url} (${response.status})`);
|
||||
}
|
||||
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global runtime catalog instance
|
||||
* 全局运行时目录实例
|
||||
*/
|
||||
export const runtimeCatalog = new RuntimeCatalog();
|
||||
@@ -6,29 +6,77 @@
|
||||
* 验证并清理资产路径以确保安全
|
||||
*/
|
||||
|
||||
/**
|
||||
* Path validation options.
|
||||
* 路径验证选项。
|
||||
*/
|
||||
export interface PathValidationOptions {
|
||||
/** Allow absolute paths (for editor environment). | 允许绝对路径(用于编辑器环境)。 */
|
||||
allowAbsolutePaths?: boolean;
|
||||
/** Allow URLs (http://, https://, asset://). | 允许 URL。 */
|
||||
allowUrls?: boolean;
|
||||
}
|
||||
|
||||
export class PathValidator {
|
||||
// Dangerous path patterns
|
||||
private static readonly DANGEROUS_PATTERNS = [
|
||||
// Dangerous path patterns (without absolute path checks)
|
||||
private static readonly DANGEROUS_PATTERNS_STRICT = [
|
||||
/\.\.[/\\]/g, // Path traversal attempts (..)
|
||||
/^[/\\]/, // Absolute paths on Unix
|
||||
/^[a-zA-Z]:[/\\]/, // Absolute paths on Windows
|
||||
/[<>:"|?*]/, // Invalid characters for Windows paths
|
||||
/\0/, // Null bytes
|
||||
/%00/, // URL encoded null bytes
|
||||
/\.\.%2[fF]/ // URL encoded path traversal
|
||||
];
|
||||
|
||||
// Valid path characters (alphanumeric, dash, underscore, dot, slash)
|
||||
// Dangerous path patterns (allowing absolute paths)
|
||||
private static readonly DANGEROUS_PATTERNS_RELAXED = [
|
||||
/\.\.[/\\]/g, // Path traversal attempts (..)
|
||||
/\0/, // Null bytes
|
||||
/%00/, // URL encoded null bytes
|
||||
/\.\.%2[fF]/ // URL encoded path traversal
|
||||
];
|
||||
|
||||
// Valid path characters for relative paths (alphanumeric, dash, underscore, dot, slash)
|
||||
private static readonly VALID_PATH_REGEX = /^[a-zA-Z0-9\-_./\\@]+$/;
|
||||
|
||||
// Valid path characters for absolute paths (includes colon for Windows drives)
|
||||
private static readonly VALID_ABSOLUTE_PATH_REGEX = /^[a-zA-Z0-9\-_./\\@:]+$/;
|
||||
|
||||
// URL pattern
|
||||
private static readonly URL_REGEX = /^(https?|asset|blob|data):\/\//;
|
||||
|
||||
// Maximum path length
|
||||
private static readonly MAX_PATH_LENGTH = 260;
|
||||
private static readonly MAX_PATH_LENGTH = 1024;
|
||||
|
||||
/** Global options for path validation. | 路径验证的全局选项。 */
|
||||
private static _globalOptions: PathValidationOptions = {
|
||||
allowAbsolutePaths: false,
|
||||
allowUrls: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Set global validation options.
|
||||
* 设置全局验证选项。
|
||||
*/
|
||||
static setGlobalOptions(options: PathValidationOptions): void {
|
||||
this._globalOptions = { ...this._globalOptions, ...options };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current global options.
|
||||
* 获取当前全局选项。
|
||||
*/
|
||||
static getGlobalOptions(): PathValidationOptions {
|
||||
return { ...this._globalOptions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a path is safe
|
||||
* 验证路径是否安全
|
||||
*/
|
||||
static validate(path: string): { valid: boolean; reason?: string } {
|
||||
static validate(path: string, options?: PathValidationOptions): { valid: boolean; reason?: string } {
|
||||
const opts = { ...this._globalOptions, ...options };
|
||||
|
||||
// Check for null/undefined/empty
|
||||
if (!path || typeof path !== 'string') {
|
||||
return { valid: false, reason: 'Path is empty or invalid' };
|
||||
@@ -39,15 +87,29 @@ export class PathValidator {
|
||||
return { valid: false, reason: `Path exceeds maximum length of ${this.MAX_PATH_LENGTH} characters` };
|
||||
}
|
||||
|
||||
// Allow URLs if enabled
|
||||
if (opts.allowUrls && this.URL_REGEX.test(path)) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Choose patterns based on options
|
||||
const patterns = opts.allowAbsolutePaths
|
||||
? this.DANGEROUS_PATTERNS_RELAXED
|
||||
: this.DANGEROUS_PATTERNS_STRICT;
|
||||
|
||||
// Check for dangerous patterns
|
||||
for (const pattern of this.DANGEROUS_PATTERNS) {
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(path)) {
|
||||
return { valid: false, reason: 'Path contains dangerous pattern' };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for valid characters
|
||||
if (!this.VALID_PATH_REGEX.test(path)) {
|
||||
const validCharsRegex = opts.allowAbsolutePaths
|
||||
? this.VALID_ABSOLUTE_PATH_REGEX
|
||||
: this.VALID_PATH_REGEX;
|
||||
|
||||
if (!validCharsRegex.test(path)) {
|
||||
return { valid: false, reason: 'Path contains invalid characters' };
|
||||
}
|
||||
|
||||
|
||||
43
packages/audio/module.json
Normal file
43
packages/audio/module.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"id": "audio",
|
||||
"name": "@esengine/audio",
|
||||
"displayName": "Audio",
|
||||
"description": "Audio playback and sound effects | 音频播放和音效",
|
||||
"version": "1.0.0",
|
||||
"category": "Audio",
|
||||
"icon": "Volume2",
|
||||
"tags": [
|
||||
"audio",
|
||||
"sound",
|
||||
"music"
|
||||
],
|
||||
"isCore": false,
|
||||
"defaultEnabled": false,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": true,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop",
|
||||
"mobile"
|
||||
],
|
||||
"dependencies": [
|
||||
"core",
|
||||
"asset-system"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"AudioSourceComponent",
|
||||
"AudioListenerComponent"
|
||||
],
|
||||
"systems": [
|
||||
"AudioSystem"
|
||||
],
|
||||
"other": [
|
||||
"AudioClip",
|
||||
"AudioMixer"
|
||||
]
|
||||
},
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js",
|
||||
"pluginExport": "AudioPlugin"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentRegistry as ComponentRegistryType } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IPlugin, PluginDescriptor } from '@esengine/engine-core';
|
||||
import type { IRuntimeModule, IPlugin, ModuleManifest } from '@esengine/engine-core';
|
||||
import { AudioSourceComponent } from './AudioSourceComponent';
|
||||
|
||||
class AudioRuntimeModule implements IRuntimeModule {
|
||||
@@ -8,17 +8,21 @@ class AudioRuntimeModule implements IRuntimeModule {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/audio',
|
||||
name: 'Audio',
|
||||
const manifest: ModuleManifest = {
|
||||
id: 'audio',
|
||||
name: '@esengine/audio',
|
||||
displayName: 'Audio',
|
||||
version: '1.0.0',
|
||||
description: '音频组件',
|
||||
category: 'audio',
|
||||
enabledByDefault: true,
|
||||
isEnginePlugin: true
|
||||
category: 'Audio',
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
dependencies: ['core', 'asset-system'],
|
||||
exports: { components: ['AudioSourceComponent'] }
|
||||
};
|
||||
|
||||
export const AudioPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
runtimeModule: new AudioRuntimeModule()
|
||||
};
|
||||
|
||||
@@ -1,36 +1,29 @@
|
||||
/**
|
||||
* Behavior Tree Plugin Descriptor
|
||||
* 行为树插件描述符
|
||||
* Behavior Tree Plugin Manifest
|
||||
* 行为树插件清单
|
||||
*/
|
||||
|
||||
import type { PluginDescriptor } from '@esengine/editor-runtime';
|
||||
import type { ModuleManifest } from '@esengine/editor-runtime';
|
||||
|
||||
/**
|
||||
* 插件描述符
|
||||
* 插件清单
|
||||
*/
|
||||
export const descriptor: PluginDescriptor = {
|
||||
export const manifest: ModuleManifest = {
|
||||
id: '@esengine/behavior-tree',
|
||||
name: 'Behavior Tree System',
|
||||
name: '@esengine/behavior-tree',
|
||||
displayName: 'Behavior Tree System',
|
||||
version: '1.0.0',
|
||||
description: 'AI 行为树系统,支持可视化编辑和运行时执行',
|
||||
category: 'ai',
|
||||
enabledByDefault: true,
|
||||
category: 'AI',
|
||||
icon: 'GitBranch',
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: false,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: false,
|
||||
modules: [
|
||||
{
|
||||
name: 'BehaviorTreeRuntime',
|
||||
type: 'runtime',
|
||||
loadingPhase: 'default'
|
||||
},
|
||||
{
|
||||
name: 'BehaviorTreeEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'default'
|
||||
}
|
||||
],
|
||||
dependencies: [
|
||||
{ id: '@esengine/engine-core', version: '>=1.0.0', optional: true }
|
||||
],
|
||||
icon: 'GitBranch'
|
||||
dependencies: ['engine-core'],
|
||||
exports: {
|
||||
components: ['BehaviorTreeRuntimeComponent'],
|
||||
systems: ['BehaviorTreeExecutionSystem'],
|
||||
loaders: ['BehaviorTreeLoader']
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,8 +42,8 @@ import { useBehaviorTreeDataStore } from './stores';
|
||||
import { createRootNode } from './domain/constants/RootNode';
|
||||
import { PluginContext } from './PluginContext';
|
||||
|
||||
// Import descriptor from local file
|
||||
import { descriptor } from './BehaviorTreePlugin';
|
||||
// Import manifest from local file
|
||||
import { manifest } from './BehaviorTreePlugin';
|
||||
|
||||
// 导入编辑器 CSS 样式(会被 vite 自动处理并注入到 DOM)
|
||||
// Import editor CSS styles (automatically handled and injected by vite)
|
||||
@@ -340,7 +340,7 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader {
|
||||
|
||||
// Create the complete plugin with editor module
|
||||
export const BehaviorTreePlugin: IPluginLoader = {
|
||||
descriptor,
|
||||
manifest,
|
||||
runtimeModule: new BehaviorTreeRuntimeModule(),
|
||||
editorModule: new BehaviorTreeEditorModule(),
|
||||
};
|
||||
|
||||
45
packages/behavior-tree/module.json
Normal file
45
packages/behavior-tree/module.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"id": "behavior-tree",
|
||||
"name": "@esengine/behavior-tree",
|
||||
"displayName": "Behavior Tree",
|
||||
"description": "AI behavior tree system | AI 行为树系统",
|
||||
"version": "1.0.0",
|
||||
"category": "AI",
|
||||
"icon": "GitBranch",
|
||||
"tags": [
|
||||
"ai",
|
||||
"behavior",
|
||||
"tree"
|
||||
],
|
||||
"isCore": false,
|
||||
"defaultEnabled": false,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": true,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop"
|
||||
],
|
||||
"dependencies": [
|
||||
"core"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"BehaviorTreeComponent"
|
||||
],
|
||||
"systems": [
|
||||
"BehaviorTreeSystem"
|
||||
],
|
||||
"other": [
|
||||
"BehaviorTree",
|
||||
"BTNode",
|
||||
"Selector",
|
||||
"Sequence",
|
||||
"Condition",
|
||||
"Action"
|
||||
]
|
||||
},
|
||||
"editorPackage": "@esengine/behavior-tree-editor",
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js",
|
||||
"pluginExport": "BehaviorTreePlugin"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { ComponentRegistry, Core } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IPlugin, PluginDescriptor, SystemContext } from '@esengine/engine-core';
|
||||
import type { IRuntimeModule, IPlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||
import type { AssetManager } from '@esengine/asset-system';
|
||||
|
||||
import { BehaviorTreeRuntimeComponent } from './execution/BehaviorTreeRuntimeComponent';
|
||||
@@ -39,7 +39,10 @@ class BehaviorTreeRuntimeModule implements IRuntimeModule {
|
||||
this._loaderRegistered = true;
|
||||
}
|
||||
|
||||
const behaviorTreeSystem = new BehaviorTreeExecutionSystem(Core);
|
||||
// 使用 context 中的 services,确保与调用方使用同一个 ServiceContainer 实例
|
||||
// Use services from context to ensure same ServiceContainer instance as caller
|
||||
const services = (btContext as any).services || Core.services;
|
||||
const behaviorTreeSystem = new BehaviorTreeExecutionSystem(services);
|
||||
|
||||
if (btContext.assetManager) {
|
||||
behaviorTreeSystem.setAssetManager(btContext.assetManager);
|
||||
@@ -54,18 +57,25 @@ class BehaviorTreeRuntimeModule implements IRuntimeModule {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/behavior-tree',
|
||||
name: 'Behavior Tree',
|
||||
const manifest: ModuleManifest = {
|
||||
id: 'behavior-tree',
|
||||
name: '@esengine/behavior-tree',
|
||||
displayName: 'Behavior Tree',
|
||||
version: '1.0.0',
|
||||
description: 'AI behavior tree system',
|
||||
category: 'ai',
|
||||
enabledByDefault: false,
|
||||
isEnginePlugin: true
|
||||
category: 'AI',
|
||||
icon: 'GitBranch',
|
||||
isCore: false,
|
||||
defaultEnabled: false,
|
||||
isEngineModule: true,
|
||||
canContainContent: true,
|
||||
dependencies: ['core'],
|
||||
exports: { components: ['BehaviorTreeComponent'] },
|
||||
editorPackage: '@esengine/behavior-tree-editor'
|
||||
};
|
||||
|
||||
export const BehaviorTreePlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
runtimeModule: new BehaviorTreeRuntimeModule()
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EntitySystem, Matcher, Entity, Time, Core, ECSSystem } from '@esengine/ecs-framework';
|
||||
import { EntitySystem, Matcher, Entity, Time, Core, ECSSystem, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type { AssetManager } from '@esengine/asset-system';
|
||||
import { BehaviorTreeRuntimeComponent } from './BehaviorTreeRuntimeComponent';
|
||||
import { BehaviorTreeAssetManager } from './BehaviorTreeAssetManager';
|
||||
@@ -6,6 +6,7 @@ import { NodeExecutorRegistry, NodeExecutionContext } from './NodeExecutor';
|
||||
import { BehaviorTreeData, BehaviorNodeData } from './BehaviorTreeData';
|
||||
import { TaskStatus } from '../Types/TaskStatus';
|
||||
import { NodeMetadataRegistry } from './NodeMetadata';
|
||||
import type { IBehaviorTreeAsset } from '../loaders/BehaviorTreeLoader';
|
||||
import './Executors';
|
||||
|
||||
/**
|
||||
@@ -17,14 +18,17 @@ import './Executors';
|
||||
export class BehaviorTreeExecutionSystem extends EntitySystem {
|
||||
private btAssetManager: BehaviorTreeAssetManager | null = null;
|
||||
private executorRegistry: NodeExecutorRegistry;
|
||||
private coreInstance: typeof Core | null = null;
|
||||
private _services: ServiceContainer | null = null;
|
||||
|
||||
/** 引用 asset-system 的 AssetManager(由 BehaviorTreeRuntimeModule 设置) */
|
||||
private _assetManager: AssetManager | null = null;
|
||||
|
||||
constructor(coreInstance?: typeof Core) {
|
||||
/** 已警告过的缺失资产,避免重复警告 */
|
||||
private _warnedMissingAssets: Set<string> = new Set();
|
||||
|
||||
constructor(services?: ServiceContainer) {
|
||||
super(Matcher.empty().all(BehaviorTreeRuntimeComponent));
|
||||
this.coreInstance = coreInstance || null;
|
||||
this._services = services || null;
|
||||
this.executorRegistry = new NodeExecutorRegistry();
|
||||
this.registerBuiltInExecutors();
|
||||
}
|
||||
@@ -121,12 +125,38 @@ export class BehaviorTreeExecutionSystem extends EntitySystem {
|
||||
|
||||
private getBTAssetManager(): BehaviorTreeAssetManager {
|
||||
if (!this.btAssetManager) {
|
||||
const core = this.coreInstance || Core;
|
||||
this.btAssetManager = core.services.resolve(BehaviorTreeAssetManager);
|
||||
// 优先使用传入的 services,否则回退到全局 Core.services
|
||||
// Prefer passed services, fallback to global Core.services
|
||||
const services = this._services || Core.services;
|
||||
if (!services) {
|
||||
throw new Error('ServiceContainer is not available. Ensure Core.create() was called.');
|
||||
}
|
||||
this.btAssetManager = services.resolve(BehaviorTreeAssetManager);
|
||||
}
|
||||
return this.btAssetManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取行为树数据
|
||||
* Get behavior tree data from AssetManager or BehaviorTreeAssetManager
|
||||
*
|
||||
* 优先从 AssetManager 获取(新方式),如果没有再从 BehaviorTreeAssetManager 获取(兼容旧方式)
|
||||
*/
|
||||
private getTreeData(assetIdOrPath: string): BehaviorTreeData | undefined {
|
||||
// 1. 优先从 AssetManager 获取(如果已加载)
|
||||
// First try AssetManager (preferred way)
|
||||
if (this._assetManager) {
|
||||
const cachedAsset = this._assetManager.getAssetByPath<IBehaviorTreeAsset>(assetIdOrPath);
|
||||
if (cachedAsset?.data) {
|
||||
return cachedAsset.data;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 回退到 BehaviorTreeAssetManager(兼容旧方式)
|
||||
// Fallback to BehaviorTreeAssetManager (legacy support)
|
||||
return this.getBTAssetManager().getAsset(assetIdOrPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册所有执行器(包括内置和插件提供的)
|
||||
*/
|
||||
@@ -158,9 +188,14 @@ export class BehaviorTreeExecutionSystem extends EntitySystem {
|
||||
continue;
|
||||
}
|
||||
|
||||
const treeData = this.getBTAssetManager().getAsset(runtime.treeAssetId);
|
||||
const treeData = this.getTreeData(runtime.treeAssetId);
|
||||
if (!treeData) {
|
||||
this.logger.warn(`未找到行为树资产: ${runtime.treeAssetId}`);
|
||||
// 只警告一次,避免每帧重复输出
|
||||
// Only warn once to avoid repeated output every frame
|
||||
if (!this._warnedMissingAssets.has(runtime.treeAssetId)) {
|
||||
this._warnedMissingAssets.add(runtime.treeAssetId);
|
||||
this.logger.warn(`未找到行为树资产: ${runtime.treeAssetId}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
|
||||
import type {
|
||||
IAssetLoader,
|
||||
IAssetMetadata,
|
||||
IAssetLoadOptions,
|
||||
IAssetLoadResult
|
||||
IAssetParseContext,
|
||||
IAssetContent,
|
||||
AssetContentType
|
||||
} from '@esengine/asset-system';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { BehaviorTreeData } from '../execution/BehaviorTreeData';
|
||||
@@ -34,60 +34,38 @@ export interface IBehaviorTreeAsset {
|
||||
export class BehaviorTreeLoader implements IAssetLoader<IBehaviorTreeAsset> {
|
||||
readonly supportedType = BehaviorTreeAssetType;
|
||||
readonly supportedExtensions = ['.btree'];
|
||||
readonly contentType: AssetContentType = 'text';
|
||||
|
||||
/**
|
||||
* 加载行为树资产
|
||||
* Load behavior tree asset
|
||||
* 从内容解析行为树资产
|
||||
* Parse behavior tree asset from content
|
||||
*/
|
||||
async load(
|
||||
path: string,
|
||||
metadata: IAssetMetadata,
|
||||
_options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<IBehaviorTreeAsset>> {
|
||||
// 获取文件系统服务
|
||||
const IFileSystemServiceKey = Symbol.for('IFileSystemService');
|
||||
const fileSystem = Core.services.tryResolve(IFileSystemServiceKey) as IFileSystem | null;
|
||||
|
||||
if (!fileSystem) {
|
||||
throw new Error('FileSystem service not available');
|
||||
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IBehaviorTreeAsset> {
|
||||
if (!content.text) {
|
||||
throw new Error('Behavior tree content is empty');
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
const content = await fileSystem.readFile(path);
|
||||
|
||||
// 转换为运行时数据
|
||||
const treeData = EditorToBehaviorTreeDataConverter.fromEditorJSON(content);
|
||||
const treeData = EditorToBehaviorTreeDataConverter.fromEditorJSON(content.text);
|
||||
|
||||
// 使用文件路径作为 ID
|
||||
treeData.id = path;
|
||||
const assetPath = context.metadata.path;
|
||||
treeData.id = assetPath;
|
||||
|
||||
// 注册到 BehaviorTreeAssetManager(保持兼容性)
|
||||
// 同时注册到 BehaviorTreeAssetManager
|
||||
// Also register to BehaviorTreeAssetManager for legacy code that uses it directly
|
||||
// (e.g., loadFromEditorJSON, or code that doesn't use AssetManager)
|
||||
const btAssetManager = Core.services.tryResolve(BehaviorTreeAssetManager);
|
||||
if (btAssetManager) {
|
||||
btAssetManager.loadAsset(treeData);
|
||||
}
|
||||
|
||||
const asset: IBehaviorTreeAsset = {
|
||||
data: treeData,
|
||||
path
|
||||
};
|
||||
|
||||
return {
|
||||
asset,
|
||||
handle: 0, // 由 AssetManager 分配
|
||||
metadata,
|
||||
loadTime: 0
|
||||
data: treeData,
|
||||
path: assetPath
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以加载
|
||||
* Check if can load this asset
|
||||
*/
|
||||
canLoad(path: string, _metadata: IAssetMetadata): boolean {
|
||||
return path.endsWith('.btree');
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资产
|
||||
* Dispose asset
|
||||
@@ -100,11 +78,3 @@ export class BehaviorTreeLoader implements IAssetLoader<IBehaviorTreeAsset> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件系统接口(简化版,仅用于类型)
|
||||
*/
|
||||
interface IFileSystem {
|
||||
readFile(path: string): Promise<string>;
|
||||
exists(path: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Core, type ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type { IPlugin, PluginDescriptor } from '@esengine/engine-core';
|
||||
import type { IPlugin, ModuleManifest } from '@esengine/engine-core';
|
||||
import type { IEditorModuleLoader, PanelDescriptor, FileActionHandler, FileCreationTemplate } from '@esengine/editor-core';
|
||||
import { MessageHub, PanelPosition } from '@esengine/editor-core';
|
||||
|
||||
@@ -95,19 +95,23 @@ class BlueprintEditorModuleImpl implements IEditorModuleLoader {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/blueprint',
|
||||
name: 'Blueprint',
|
||||
name: '@esengine/blueprint',
|
||||
displayName: 'Blueprint',
|
||||
version: '1.0.0',
|
||||
description: 'Visual scripting system for ECS Framework',
|
||||
category: 'scripting',
|
||||
enabledByDefault: false,
|
||||
isEnginePlugin: true,
|
||||
category: 'Other',
|
||||
isCore: false,
|
||||
defaultEnabled: false,
|
||||
isEngineModule: true,
|
||||
canContainContent: true,
|
||||
modules: [
|
||||
{ name: 'Runtime', type: 'runtime', loadingPhase: 'default' },
|
||||
{ name: 'Editor', type: 'editor', loadingPhase: 'postDefault' }
|
||||
]
|
||||
dependencies: ['engine-core'],
|
||||
exports: {
|
||||
components: ['BlueprintComponent'],
|
||||
systems: ['BlueprintSystem'],
|
||||
other: ['NodeRegistry', 'BlueprintVM']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -115,7 +119,7 @@ const descriptor: PluginDescriptor = {
|
||||
* 完整的蓝图插件,包含运行时和编辑器模块
|
||||
*/
|
||||
export const BlueprintPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
editorModule: new BlueprintEditorModuleImpl()
|
||||
};
|
||||
|
||||
|
||||
43
packages/blueprint/module.json
Normal file
43
packages/blueprint/module.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"id": "blueprint",
|
||||
"name": "@esengine/blueprint",
|
||||
"displayName": "Blueprint",
|
||||
"description": "Visual scripting system | 可视化脚本系统",
|
||||
"version": "1.0.0",
|
||||
"category": "AI",
|
||||
"icon": "Workflow",
|
||||
"tags": [
|
||||
"visual",
|
||||
"scripting",
|
||||
"blueprint",
|
||||
"nodes"
|
||||
],
|
||||
"isCore": false,
|
||||
"defaultEnabled": false,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": true,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop"
|
||||
],
|
||||
"dependencies": [
|
||||
"core"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"BlueprintComponent"
|
||||
],
|
||||
"systems": [
|
||||
"BlueprintSystem"
|
||||
],
|
||||
"other": [
|
||||
"Blueprint",
|
||||
"BlueprintNode",
|
||||
"BlueprintGraph"
|
||||
]
|
||||
},
|
||||
"editorPackage": "@esengine/blueprint-editor",
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js",
|
||||
"pluginExport": "BlueprintPlugin"
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"@types/node": "^20.19.17",
|
||||
"rimraf": "^5.0.0",
|
||||
|
||||
60
packages/blueprint/src/BlueprintPlugin.ts
Normal file
60
packages/blueprint/src/BlueprintPlugin.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Blueprint Plugin for ES Engine.
|
||||
* ES引擎的蓝图插件。
|
||||
*
|
||||
* Provides visual scripting runtime support.
|
||||
* 提供可视化脚本运行时支持。
|
||||
*/
|
||||
|
||||
import type { IPlugin, ModuleManifest, IRuntimeModule } from '@esengine/engine-core';
|
||||
|
||||
/**
|
||||
* Blueprint Runtime Module.
|
||||
* 蓝图运行时模块。
|
||||
*
|
||||
* Note: Blueprint uses a custom system (IBlueprintSystem) instead of EntitySystem,
|
||||
* so createSystems is not implemented here. Blueprint systems should be created
|
||||
* manually using createBlueprintSystem(scene).
|
||||
*/
|
||||
class BlueprintRuntimeModule implements IRuntimeModule {
|
||||
async onInitialize(): Promise<void> {
|
||||
// Blueprint system initialization
|
||||
// Blueprint uses IBlueprintSystem which is different from EntitySystem
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
// Cleanup
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin manifest for Blueprint.
|
||||
* 蓝图的插件清单。
|
||||
*/
|
||||
const manifest: ModuleManifest = {
|
||||
id: 'blueprint',
|
||||
name: '@esengine/blueprint',
|
||||
displayName: 'Blueprint',
|
||||
version: '1.0.0',
|
||||
description: '可视化脚本系统',
|
||||
category: 'AI',
|
||||
icon: 'Workflow',
|
||||
isCore: false,
|
||||
defaultEnabled: false,
|
||||
isEngineModule: true,
|
||||
dependencies: ['core'],
|
||||
exports: {
|
||||
components: ['BlueprintComponent'],
|
||||
systems: ['BlueprintSystem']
|
||||
},
|
||||
requiresWasm: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Blueprint Plugin.
|
||||
* 蓝图插件。
|
||||
*/
|
||||
export const BlueprintPlugin: IPlugin = {
|
||||
manifest,
|
||||
runtimeModule: new BlueprintRuntimeModule()
|
||||
};
|
||||
@@ -29,3 +29,6 @@ export {
|
||||
triggerCustomBlueprintEvent
|
||||
} from './runtime/BlueprintSystem';
|
||||
export { createEmptyBlueprint, validateBlueprintAsset } from './types/blueprint';
|
||||
|
||||
// Plugin
|
||||
export { BlueprintPlugin } from './BlueprintPlugin';
|
||||
|
||||
@@ -4,31 +4,33 @@
|
||||
* 插件定义 - 注册编辑器模块(Inspector、工具等)
|
||||
*/
|
||||
|
||||
import type { IPluginLoader, PluginDescriptor, IEditorModuleLoader } from '@esengine/ecs-components';
|
||||
import type { IPlugin, ModuleManifest, IEditorModuleLoader } from '@esengine/editor-core';
|
||||
import { {{name}}RuntimeModule } from '../{{name}}RuntimeModule';
|
||||
|
||||
class {{name}}EditorModule implements IEditorModuleLoader {
|
||||
registerInspectors(registry: any): void {
|
||||
async install(): Promise<void> {
|
||||
// 注册组件 Inspector
|
||||
// registry.register('MyComponent', MyComponentInspector);
|
||||
}
|
||||
async uninstall(): Promise<void> {}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/{{name}}',
|
||||
name: '{{displayName}}',
|
||||
name: '@esengine/{{name}}',
|
||||
displayName: '{{displayName}}',
|
||||
version: '1.0.0',
|
||||
description: '{{displayName}} plugin',
|
||||
category: '{{category}}',
|
||||
enabledByDefault: true,
|
||||
isEnginePlugin: false,
|
||||
modules: [
|
||||
{ name: '{{name}}Runtime', type: 'runtime', entry: './src/runtime.ts' },
|
||||
{ name: '{{name}}Editor', type: 'editor', entry: './src/editor/index.ts' }
|
||||
]
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: false,
|
||||
dependencies: ['engine-core'],
|
||||
exports: {}
|
||||
};
|
||||
|
||||
export const {{name}}Plugin: IPluginLoader = {
|
||||
descriptor,
|
||||
export const {{name}}Plugin: IPlugin = {
|
||||
manifest,
|
||||
runtimeModule: new {{name}}RuntimeModule(),
|
||||
editorModule: new {{name}}EditorModule()
|
||||
};
|
||||
|
||||
38
packages/camera/module.json
Normal file
38
packages/camera/module.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "camera",
|
||||
"name": "@esengine/camera",
|
||||
"displayName": "Camera",
|
||||
"description": "Camera and viewport management | 相机和视口管理",
|
||||
"version": "1.0.0",
|
||||
"category": "Rendering",
|
||||
"icon": "Video",
|
||||
"tags": [
|
||||
"camera",
|
||||
"viewport",
|
||||
"rendering"
|
||||
],
|
||||
"isCore": false,
|
||||
"defaultEnabled": true,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": false,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop",
|
||||
"mobile"
|
||||
],
|
||||
"dependencies": [
|
||||
"core",
|
||||
"math"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"CameraComponent"
|
||||
],
|
||||
"systems": [
|
||||
"CameraSystem"
|
||||
]
|
||||
},
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js",
|
||||
"pluginExport": "CameraPlugin"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentRegistry as ComponentRegistryType } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IPlugin, PluginDescriptor } from '@esengine/engine-core';
|
||||
import type { IRuntimeModule, IPlugin, ModuleManifest } from '@esengine/engine-core';
|
||||
import { CameraComponent } from './CameraComponent';
|
||||
|
||||
class CameraRuntimeModule implements IRuntimeModule {
|
||||
@@ -8,17 +8,21 @@ class CameraRuntimeModule implements IRuntimeModule {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/camera',
|
||||
name: 'Camera',
|
||||
const manifest: ModuleManifest = {
|
||||
id: 'camera',
|
||||
name: '@esengine/camera',
|
||||
displayName: 'Camera',
|
||||
version: '1.0.0',
|
||||
description: '2D/3D 相机组件',
|
||||
category: 'core',
|
||||
enabledByDefault: true,
|
||||
isEnginePlugin: true
|
||||
category: 'Rendering',
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
dependencies: ['core', 'math'],
|
||||
exports: { components: ['CameraComponent'] }
|
||||
};
|
||||
|
||||
export const CameraPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
runtimeModule: new CameraRuntimeModule()
|
||||
};
|
||||
|
||||
23
packages/core/module.json
Normal file
23
packages/core/module.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"id": "core",
|
||||
"name": "@esengine/ecs-framework",
|
||||
"displayName": "Core ECS",
|
||||
"outputPath": "dist/index.mjs",
|
||||
"description": "Core Entity-Component-System framework | 核心 ECS 框架",
|
||||
"version": "1.0.0",
|
||||
"category": "Core",
|
||||
"icon": "Box",
|
||||
"tags": ["ecs", "entity", "component", "system"],
|
||||
"isCore": true,
|
||||
"defaultEnabled": true,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": false,
|
||||
"platforms": ["web", "desktop", "mobile"],
|
||||
"dependencies": [],
|
||||
"exports": {
|
||||
"components": ["Component", "Transform"],
|
||||
"systems": ["System"],
|
||||
"other": ["World", "Entity", "EntityManager", "SystemManager"]
|
||||
},
|
||||
"requiresWasm": false
|
||||
}
|
||||
@@ -30,10 +30,10 @@
|
||||
"clean": "rimraf bin dist tsconfig.tsbuildinfo",
|
||||
"build:ts": "tsc",
|
||||
"prebuild": "npm run clean",
|
||||
"build": "npm run build:ts",
|
||||
"build": "npm run build:ts && node build-rollup.cjs",
|
||||
"build:watch": "tsc --watch",
|
||||
"rebuild": "npm run clean && npm run build",
|
||||
"build:npm": "npm run build && node build-rollup.cjs",
|
||||
"build:npm": "npm run build",
|
||||
"test": "jest --config jest.config.cjs",
|
||||
"test:watch": "jest --watch --config jest.config.cjs",
|
||||
"test:performance": "jest --config jest.performance.config.cjs",
|
||||
|
||||
@@ -70,7 +70,7 @@ module.exports = [
|
||||
warn(warning);
|
||||
},
|
||||
treeshake: {
|
||||
moduleSideEffects: false,
|
||||
moduleSideEffects: (id) => id.includes('reflect-metadata'),
|
||||
propertyReadSideEffects: false,
|
||||
unknownGlobalSideEffects: false
|
||||
}
|
||||
@@ -102,7 +102,7 @@ module.exports = [
|
||||
warn(warning);
|
||||
},
|
||||
treeshake: {
|
||||
moduleSideEffects: false
|
||||
moduleSideEffects: (id) => id.includes('reflect-metadata')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -133,7 +133,7 @@ module.exports = [
|
||||
warn(warning);
|
||||
},
|
||||
treeshake: {
|
||||
moduleSideEffects: false
|
||||
moduleSideEffects: (id) => id.includes('reflect-metadata')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -193,10 +193,10 @@ module.exports = [
|
||||
warn(warning);
|
||||
},
|
||||
treeshake: {
|
||||
moduleSideEffects: false
|
||||
moduleSideEffects: (id) => id.includes('reflect-metadata')
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// 类型定义构建
|
||||
{
|
||||
input: 'bin/index.d.ts',
|
||||
|
||||
21
packages/ecs-engine-bindgen/module.json
Normal file
21
packages/ecs-engine-bindgen/module.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "ecs-engine-bindgen",
|
||||
"name": "@esengine/ecs-engine-bindgen",
|
||||
"displayName": "Engine Bindgen",
|
||||
"description": "Bridge between ECS and Rust Engine | ECS 与 Rust 引擎之间的桥接层",
|
||||
"version": "0.1.0",
|
||||
"category": "Core",
|
||||
"icon": "Link",
|
||||
"tags": ["engine", "bindgen", "wasm", "rendering"],
|
||||
"isCore": true,
|
||||
"defaultEnabled": true,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": false,
|
||||
"platforms": ["web", "desktop"],
|
||||
"dependencies": ["core", "math"],
|
||||
"exports": {
|
||||
"other": ["EngineBridge", "EngineRenderSystem", "CameraSystem"]
|
||||
},
|
||||
"requiresWasm": true,
|
||||
"outputPath": "dist/index.js"
|
||||
}
|
||||
@@ -39,10 +39,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/ecs-framework-math": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/sprite": "workspace:*",
|
||||
"@esengine/camera": "workspace:*",
|
||||
"@esengine/asset-system": "workspace:*",
|
||||
"@esengine/material-system": "workspace:*",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.8.0",
|
||||
"rimraf": "^5.0.0"
|
||||
|
||||
@@ -58,6 +58,7 @@ export class EngineBridge implements IEngineBridge {
|
||||
private textureIdBuffer: Uint32Array;
|
||||
private uvBuffer: Float32Array;
|
||||
private colorBuffer: Uint32Array;
|
||||
private materialIdBuffer: Uint32Array;
|
||||
|
||||
// Statistics | 统计信息
|
||||
private stats: EngineStats = {
|
||||
@@ -92,6 +93,7 @@ export class EngineBridge implements IEngineBridge {
|
||||
this.textureIdBuffer = new Uint32Array(maxSprites);
|
||||
this.uvBuffer = new Float32Array(maxSprites * 4); // u0, v0, u1, v1
|
||||
this.colorBuffer = new Uint32Array(maxSprites);
|
||||
this.materialIdBuffer = new Uint32Array(maxSprites);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,6 +237,9 @@ export class EngineBridge implements IEngineBridge {
|
||||
|
||||
// Color | 颜色
|
||||
this.colorBuffer[i] = sprite.color;
|
||||
|
||||
// Material ID (0 = default) | 材质ID(0 = 默认)
|
||||
this.materialIdBuffer[i] = sprite.materialId ?? 0;
|
||||
}
|
||||
|
||||
// Submit to engine (single WASM call) | 提交到引擎(单次WASM调用)
|
||||
@@ -242,7 +247,8 @@ export class EngineBridge implements IEngineBridge {
|
||||
this.transformBuffer.subarray(0, count * 7),
|
||||
this.textureIdBuffer.subarray(0, count),
|
||||
this.uvBuffer.subarray(0, count * 4),
|
||||
this.colorBuffer.subarray(0, count)
|
||||
this.colorBuffer.subarray(0, count),
|
||||
this.materialIdBuffer.subarray(0, count)
|
||||
);
|
||||
|
||||
this.stats.spriteCount = count;
|
||||
|
||||
@@ -74,10 +74,14 @@ export class RenderBatcher {
|
||||
* @returns Sorted array of sprites | 排序后的精灵数组
|
||||
*/
|
||||
getSprites(): SpriteRenderData[] {
|
||||
// Sort by texture ID for better batching (fewer texture switches)
|
||||
// 按纹理ID排序以获得更好的批处理效果(减少纹理切换)
|
||||
// Sort by material ID first, then texture ID for better batching
|
||||
// 先按材质ID排序,再按纹理ID排序以获得更好的批处理效果
|
||||
if (!this.sortByZ) {
|
||||
this.sprites.sort((a, b) => a.textureId - b.textureId);
|
||||
this.sprites.sort((a, b) => {
|
||||
const materialDiff = (a.materialId || 0) - (b.materialId || 0);
|
||||
if (materialDiff !== 0) return materialDiff;
|
||||
return a.textureId - b.textureId;
|
||||
});
|
||||
}
|
||||
return this.sprites;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Entity, Component } from '@esengine/ecs-framework';
|
||||
import type { EngineBridge } from './EngineBridge';
|
||||
import { RenderBatcher } from './RenderBatcher';
|
||||
import { SpriteComponent } from '@esengine/sprite';
|
||||
import { getMaterialManager } from '@esengine/material-system';
|
||||
import type { SpriteRenderData } from '../types';
|
||||
|
||||
/**
|
||||
@@ -108,6 +109,14 @@ export class SpriteRenderHelper {
|
||||
// Convert hex color string to packed RGBA
|
||||
const color = this.hexToPackedColor(sprite.color, sprite.alpha);
|
||||
|
||||
// Get material ID from path (0 = default if not found or no path specified)
|
||||
const materialId = sprite.material
|
||||
? getMaterialManager().getMaterialIdByPath(sprite.material)
|
||||
: 0;
|
||||
|
||||
// Collect material overrides if any
|
||||
const hasOverrides = sprite.hasOverrides();
|
||||
|
||||
const renderData: SpriteRenderData = {
|
||||
x: transform.position.x,
|
||||
y: transform.position.y,
|
||||
@@ -118,7 +127,10 @@ export class SpriteRenderHelper {
|
||||
originY: sprite.originY,
|
||||
textureId: sprite.textureId,
|
||||
uv,
|
||||
color
|
||||
color,
|
||||
materialId,
|
||||
// Only include overrides if there are any
|
||||
...(hasOverrides ? { materialOverrides: sprite.materialOverrides } : {})
|
||||
};
|
||||
|
||||
this.batcher.addSprite(renderData);
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ComponentType, ECSSystem, Component, Core } from '@esengine/ecs-framework';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { Color } from '@esengine/ecs-framework-math';
|
||||
import { SpriteComponent } from '@esengine/sprite';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
import { getMaterialManager } from '@esengine/material-system';
|
||||
import type { EngineBridge } from '../core/EngineBridge';
|
||||
import { RenderBatcher } from '../core/RenderBatcher';
|
||||
import type { SpriteRenderData } from '../types';
|
||||
@@ -279,7 +281,7 @@ export class EngineRenderSystem extends EntitySystem {
|
||||
: (typeof transform.rotation === 'number' ? transform.rotation : transform.rotation.z);
|
||||
|
||||
// Convert hex color string to packed RGBA | 将十六进制颜色字符串转换为打包的RGBA
|
||||
const color = this.hexToPackedColor(sprite.color, sprite.alpha);
|
||||
const color = Color.packHexAlpha(sprite.color, sprite.alpha);
|
||||
|
||||
// Get texture ID from sprite component
|
||||
// 从精灵组件获取纹理ID
|
||||
@@ -290,6 +292,16 @@ export class EngineRenderSystem extends EntitySystem {
|
||||
textureId = this.bridge.getOrLoadTextureByPath(sprite.texture);
|
||||
}
|
||||
|
||||
// Get material ID from path (0 = default if not found or no path specified)
|
||||
// 从路径获取材质 ID(0 = 默认,如果未找到或未指定路径)
|
||||
const materialId = sprite.material
|
||||
? getMaterialManager().getMaterialIdByPath(sprite.material)
|
||||
: 0;
|
||||
|
||||
// Collect material overrides if any
|
||||
// 收集材质覆盖(如果有)
|
||||
const hasOverrides = sprite.hasOverrides();
|
||||
|
||||
// Pass actual display dimensions (sprite size * world transform scale)
|
||||
// 传递实际显示尺寸(sprite尺寸 * 世界变换缩放)
|
||||
const renderData: SpriteRenderData = {
|
||||
@@ -302,7 +314,11 @@ export class EngineRenderSystem extends EntitySystem {
|
||||
originY: sprite.anchorY,
|
||||
textureId,
|
||||
uv,
|
||||
color
|
||||
color,
|
||||
materialId,
|
||||
// Only include overrides if there are any
|
||||
// 仅在有覆盖时包含
|
||||
...(hasOverrides ? { materialOverrides: sprite.materialOverrides } : {})
|
||||
};
|
||||
|
||||
renderItems.push({ sortingOrder: sprite.sortingOrder, sprites: [renderData] });
|
||||
@@ -1054,32 +1070,6 @@ export class EngineRenderSystem extends EntitySystem {
|
||||
return this.transformMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex color string to packed RGBA.
|
||||
* 将十六进制颜色字符串转换为打包的RGBA。
|
||||
*/
|
||||
private hexToPackedColor(hex: string, alpha: number): number {
|
||||
let r = 255, g = 255, b = 255;
|
||||
|
||||
if (typeof hex === 'string' && hex.startsWith('#')) {
|
||||
const hexValue = hex.slice(1);
|
||||
if (hexValue.length === 3) {
|
||||
r = parseInt(hexValue[0] + hexValue[0], 16);
|
||||
g = parseInt(hexValue[1] + hexValue[1], 16);
|
||||
b = parseInt(hexValue[2] + hexValue[2], 16);
|
||||
} else if (hexValue.length === 6) {
|
||||
r = parseInt(hexValue.slice(0, 2), 16);
|
||||
g = parseInt(hexValue.slice(2, 4), 16);
|
||||
b = parseInt(hexValue.slice(4, 6), 16);
|
||||
}
|
||||
}
|
||||
|
||||
const a = Math.round(alpha * 255);
|
||||
// Pack as 0xAABBGGRR for WebGL
|
||||
return ((a & 0xFF) << 24) | ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Register a render data provider.
|
||||
* 注册渲染数据提供者。
|
||||
|
||||
@@ -3,6 +3,23 @@
|
||||
* 引擎桥接层的类型定义。
|
||||
*/
|
||||
|
||||
/**
|
||||
* Material property override for rendering.
|
||||
* 用于渲染的材质属性覆盖。
|
||||
*/
|
||||
export interface MaterialPropertyOverride {
|
||||
/** Uniform type. | Uniform 类型。 */
|
||||
type: 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int';
|
||||
/** Uniform value. | Uniform 值。 */
|
||||
value: number | number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Material overrides map.
|
||||
* 材质覆盖映射。
|
||||
*/
|
||||
export type MaterialOverrides = Record<string, MaterialPropertyOverride>;
|
||||
|
||||
/**
|
||||
* Sprite render data for batch submission.
|
||||
* 用于批量提交的精灵渲染数据。
|
||||
@@ -28,6 +45,13 @@ export interface SpriteRenderData {
|
||||
uv: [number, number, number, number];
|
||||
/** Packed RGBA color. | 打包的RGBA颜色。 */
|
||||
color: number;
|
||||
/** Material ID (0 = default material). | 材质ID(0 = 默认材质)。 */
|
||||
materialId?: number;
|
||||
/**
|
||||
* Material property overrides (instance level).
|
||||
* 材质属性覆盖(实例级别)。
|
||||
*/
|
||||
materialOverrides?: MaterialOverrides;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
107
packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts
vendored
107
packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts
vendored
@@ -23,6 +23,11 @@ export class GameEngine {
|
||||
* Array of [x, y, zoom, rotation] | 数组 [x, y, zoom, rotation]
|
||||
*/
|
||||
getCamera(): Float32Array;
|
||||
/**
|
||||
* Check if a shader exists.
|
||||
* 检查着色器是否存在。
|
||||
*/
|
||||
hasShader(shader_id: number): boolean;
|
||||
/**
|
||||
* Set camera position, zoom, and rotation.
|
||||
* 设置相机位置、缩放和旋转。
|
||||
@@ -42,6 +47,11 @@ export class GameEngine {
|
||||
* * `key_code` - The key code to check | 要检查的键码
|
||||
*/
|
||||
isKeyDown(key_code: string): boolean;
|
||||
/**
|
||||
* Check if a material exists.
|
||||
* 检查材质是否存在。
|
||||
*/
|
||||
hasMaterial(material_id: number): boolean;
|
||||
/**
|
||||
* Load a texture from URL.
|
||||
* 从URL加载纹理。
|
||||
@@ -64,6 +74,11 @@ export class GameEngine {
|
||||
* 适用于微信小游戏等环境。
|
||||
*/
|
||||
static fromExternal(gl_context: any, width: number, height: number): GameEngine;
|
||||
/**
|
||||
* Remove a shader.
|
||||
* 移除着色器。
|
||||
*/
|
||||
removeShader(shader_id: number): boolean;
|
||||
/**
|
||||
* Set grid visibility.
|
||||
* 设置网格可见性。
|
||||
@@ -90,6 +105,18 @@ export class GameEngine {
|
||||
* * `show_handles` - Whether to show transform handles | 是否显示变换手柄
|
||||
*/
|
||||
addGizmoRect(x: number, y: number, width: number, height: number, rotation: number, origin_x: number, origin_y: number, r: number, g: number, b: number, a: number, show_handles: boolean): void;
|
||||
/**
|
||||
* Compile and register a custom shader.
|
||||
* 编译并注册自定义着色器。
|
||||
*
|
||||
* # Arguments | 参数
|
||||
* * `vertex_source` - Vertex shader GLSL source | 顶点着色器GLSL源代码
|
||||
* * `fragment_source` - Fragment shader GLSL source | 片段着色器GLSL源代码
|
||||
*
|
||||
* # Returns | 返回
|
||||
* The shader ID for referencing this shader | 用于引用此着色器的ID
|
||||
*/
|
||||
compileShader(vertex_source: string, fragment_source: string): number;
|
||||
/**
|
||||
* Render sprites as overlay (without clearing screen).
|
||||
* 渲染精灵作为叠加层(不清除屏幕)。
|
||||
@@ -98,6 +125,24 @@ export class GameEngine {
|
||||
* 用于在世界内容上渲染 UI。
|
||||
*/
|
||||
renderOverlay(): void;
|
||||
/**
|
||||
* Create and register a new material.
|
||||
* 创建并注册新材质。
|
||||
*
|
||||
* # Arguments | 参数
|
||||
* * `name` - Material name for debugging | 材质名称(用于调试)
|
||||
* * `shader_id` - Shader ID to use | 使用的着色器ID
|
||||
* * `blend_mode` - Blend mode: 0=None, 1=Alpha, 2=Additive, 3=Multiply, 4=Screen, 5=PremultipliedAlpha
|
||||
*
|
||||
* # Returns | 返回
|
||||
* The material ID for referencing this material | 用于引用此材质的ID
|
||||
*/
|
||||
createMaterial(name: string, shader_id: number, blend_mode: number): number;
|
||||
/**
|
||||
* Remove a material.
|
||||
* 移除材质。
|
||||
*/
|
||||
removeMaterial(material_id: number): boolean;
|
||||
/**
|
||||
* Resize a specific viewport.
|
||||
* 调整特定视口大小。
|
||||
@@ -140,11 +185,36 @@ export class GameEngine {
|
||||
* * `canvas_id` - HTML canvas element ID | HTML canvas元素ID
|
||||
*/
|
||||
registerViewport(id: string, canvas_id: string): void;
|
||||
/**
|
||||
* Set a material's vec2 uniform.
|
||||
* 设置材质的vec2 uniform。
|
||||
*/
|
||||
setMaterialVec2(material_id: number, name: string, x: number, y: number): boolean;
|
||||
/**
|
||||
* Set a material's vec3 uniform.
|
||||
* 设置材质的vec3 uniform。
|
||||
*/
|
||||
setMaterialVec3(material_id: number, name: string, x: number, y: number, z: number): boolean;
|
||||
/**
|
||||
* Set a material's vec4 uniform (also used for colors).
|
||||
* 设置材质的vec4 uniform(也用于颜色)。
|
||||
*/
|
||||
setMaterialVec4(material_id: number, name: string, x: number, y: number, z: number, w: number): boolean;
|
||||
/**
|
||||
* Render to a specific viewport.
|
||||
* 渲染到特定视口。
|
||||
*/
|
||||
renderToViewport(viewport_id: string): void;
|
||||
/**
|
||||
* Set a material's color uniform (RGBA, 0.0-1.0).
|
||||
* 设置材质的颜色uniform(RGBA,0.0-1.0)。
|
||||
*/
|
||||
setMaterialColor(material_id: number, name: string, r: number, g: number, b: number, a: number): boolean;
|
||||
/**
|
||||
* Set a material's float uniform.
|
||||
* 设置材质的浮点uniform。
|
||||
*/
|
||||
setMaterialFloat(material_id: number, name: string, value: number): boolean;
|
||||
/**
|
||||
* Set transform tool mode.
|
||||
* 设置变换工具模式。
|
||||
@@ -191,8 +261,9 @@ export class GameEngine {
|
||||
* * `texture_ids` - Uint32Array of texture IDs | 纹理ID数组
|
||||
* * `uvs` - Float32Array [u0, v0, u1, v1] per sprite | 每个精灵的UV坐标
|
||||
* * `colors` - Uint32Array of packed RGBA colors | 打包的RGBA颜色数组
|
||||
* * `material_ids` - Uint32Array of material IDs (0 = default) | 材质ID数组(0 = 默认)
|
||||
*/
|
||||
submitSpriteBatch(transforms: Float32Array, texture_ids: Uint32Array, uvs: Float32Array, colors: Uint32Array): void;
|
||||
submitSpriteBatch(transforms: Float32Array, texture_ids: Uint32Array, uvs: Float32Array, colors: Uint32Array, material_ids: Uint32Array): void;
|
||||
/**
|
||||
* Unregister a viewport.
|
||||
* 注销视口。
|
||||
@@ -206,6 +277,11 @@ export class GameEngine {
|
||||
* * `path` - Image path/URL to load | 要加载的图片路径/URL
|
||||
*/
|
||||
loadTextureByPath(path: string): number;
|
||||
/**
|
||||
* Compile a shader with a specific ID.
|
||||
* 使用特定ID编译着色器。
|
||||
*/
|
||||
compileShaderWithId(shader_id: number, vertex_source: string, fragment_source: string): void;
|
||||
/**
|
||||
* Get texture ID by path.
|
||||
* 按路径获取纹理ID。
|
||||
@@ -214,6 +290,19 @@ export class GameEngine {
|
||||
* * `path` - Image path to lookup | 要查找的图片路径
|
||||
*/
|
||||
getTextureIdByPath(path: string): number | undefined;
|
||||
/**
|
||||
* Create a material with a specific ID.
|
||||
* 使用特定ID创建材质。
|
||||
*/
|
||||
createMaterialWithId(material_id: number, name: string, shader_id: number, blend_mode: number): void;
|
||||
/**
|
||||
* Set a material's blend mode.
|
||||
* 设置材质的混合模式。
|
||||
*
|
||||
* # Arguments | 参数
|
||||
* * `blend_mode` - 0=None, 1=Alpha, 2=Additive, 3=Multiply, 4=Screen, 5=PremultipliedAlpha
|
||||
*/
|
||||
setMaterialBlendMode(material_id: number, blend_mode: number): boolean;
|
||||
/**
|
||||
* Create a new game engine instance.
|
||||
* 创建新的游戏引擎实例。
|
||||
@@ -272,18 +361,26 @@ export interface InitOutput {
|
||||
readonly gameengine_addGizmoLine: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => void;
|
||||
readonly gameengine_addGizmoRect: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number) => void;
|
||||
readonly gameengine_clear: (a: number, b: number, c: number, d: number, e: number) => void;
|
||||
readonly gameengine_compileShader: (a: number, b: number, c: number, d: number, e: number) => [number, number, number];
|
||||
readonly gameengine_compileShaderWithId: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number];
|
||||
readonly gameengine_createMaterial: (a: number, b: number, c: number, d: number, e: number) => number;
|
||||
readonly gameengine_createMaterialWithId: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
||||
readonly gameengine_fromExternal: (a: any, b: number, c: number) => [number, number, number];
|
||||
readonly gameengine_getCamera: (a: number) => [number, number];
|
||||
readonly gameengine_getOrLoadTextureByPath: (a: number, b: number, c: number) => [number, number, number];
|
||||
readonly gameengine_getTextureIdByPath: (a: number, b: number, c: number) => number;
|
||||
readonly gameengine_getViewportCamera: (a: number, b: number, c: number) => [number, number];
|
||||
readonly gameengine_getViewportIds: (a: number) => [number, number];
|
||||
readonly gameengine_hasMaterial: (a: number, b: number) => number;
|
||||
readonly gameengine_hasShader: (a: number, b: number) => number;
|
||||
readonly gameengine_height: (a: number) => number;
|
||||
readonly gameengine_isKeyDown: (a: number, b: number, c: number) => number;
|
||||
readonly gameengine_loadTexture: (a: number, b: number, c: number, d: number) => [number, number];
|
||||
readonly gameengine_loadTextureByPath: (a: number, b: number, c: number) => [number, number, number];
|
||||
readonly gameengine_new: (a: number, b: number) => [number, number, number];
|
||||
readonly gameengine_registerViewport: (a: number, b: number, c: number, d: number, e: number) => [number, number];
|
||||
readonly gameengine_removeMaterial: (a: number, b: number) => number;
|
||||
readonly gameengine_removeShader: (a: number, b: number) => number;
|
||||
readonly gameengine_render: (a: number) => [number, number];
|
||||
readonly gameengine_renderOverlay: (a: number) => [number, number];
|
||||
readonly gameengine_renderToViewport: (a: number, b: number, c: number) => [number, number];
|
||||
@@ -292,12 +389,18 @@ export interface InitOutput {
|
||||
readonly gameengine_setActiveViewport: (a: number, b: number, c: number) => number;
|
||||
readonly gameengine_setCamera: (a: number, b: number, c: number, d: number, e: number) => void;
|
||||
readonly gameengine_setClearColor: (a: number, b: number, c: number, d: number, e: number) => void;
|
||||
readonly gameengine_setMaterialBlendMode: (a: number, b: number, c: number) => number;
|
||||
readonly gameengine_setMaterialColor: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => number;
|
||||
readonly gameengine_setMaterialFloat: (a: number, b: number, c: number, d: number, e: number) => number;
|
||||
readonly gameengine_setMaterialVec2: (a: number, b: number, c: number, d: number, e: number, f: number) => number;
|
||||
readonly gameengine_setMaterialVec3: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => number;
|
||||
readonly gameengine_setMaterialVec4: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => number;
|
||||
readonly gameengine_setShowGizmos: (a: number, b: number) => void;
|
||||
readonly gameengine_setShowGrid: (a: number, b: number) => void;
|
||||
readonly gameengine_setTransformMode: (a: number, b: number) => void;
|
||||
readonly gameengine_setViewportCamera: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void;
|
||||
readonly gameengine_setViewportConfig: (a: number, b: number, c: number, d: number, e: number) => void;
|
||||
readonly gameengine_submitSpriteBatch: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => [number, number];
|
||||
readonly gameengine_submitSpriteBatch: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number) => [number, number];
|
||||
readonly gameengine_unregisterViewport: (a: number, b: number, c: number) => void;
|
||||
readonly gameengine_updateInput: (a: number) => void;
|
||||
readonly gameengine_width: (a: number) => number;
|
||||
|
||||
@@ -9,20 +9,26 @@
|
||||
"build": "npm run build:sdk && tsc && vite build",
|
||||
"build:watch": "vite build --watch",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "npm run build:sdk && tauri dev",
|
||||
"copy-modules": "node ../../scripts/copy-engine-modules.mjs",
|
||||
"tauri:dev": "npm run build:sdk && npm run copy-modules && tauri dev",
|
||||
"bundle:runtime": "node scripts/bundle-runtime.mjs",
|
||||
"tauri:build": "npm run build:sdk && npm run bundle:runtime && tauri build",
|
||||
"tauri:build": "npm run build:sdk && npm run copy-modules && npm run bundle:runtime && tauri build",
|
||||
"version": "node scripts/sync-version.js && git add src-tauri/tauri.conf.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/asset-system": "workspace:*",
|
||||
"@esengine/asset-system-editor": "workspace:*",
|
||||
"@esengine/behavior-tree": "workspace:*",
|
||||
"@esengine/material-system": "workspace:*",
|
||||
"@esengine/material-editor": "workspace:*",
|
||||
"@esengine/behavior-tree-editor": "workspace:*",
|
||||
"@esengine/blueprint": "workspace:*",
|
||||
"@esengine/blueprint-editor": "workspace:*",
|
||||
"@esengine/editor-runtime": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/sprite": "workspace:*",
|
||||
"@esengine/sprite-editor": "workspace:*",
|
||||
"@esengine/shader-editor": "workspace:*",
|
||||
"@esengine/camera": "workspace:*",
|
||||
"@esengine/audio": "workspace:*",
|
||||
"@esengine/physics-rapier2d": "workspace:*",
|
||||
|
||||
101
packages/editor-app/src-tauri/Cargo.lock
generated
101
packages/editor-app/src-tauri/Cargo.lock
generated
@@ -1100,6 +1100,8 @@ dependencies = [
|
||||
"futures-util",
|
||||
"glob",
|
||||
"image",
|
||||
"notify",
|
||||
"notify-debouncer-mini",
|
||||
"once_cell",
|
||||
"qrcode",
|
||||
"serde",
|
||||
@@ -1379,6 +1381,15 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
@@ -2205,6 +2216,26 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify-sys"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
@@ -2214,6 +2245,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instant"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interpolate_name"
|
||||
version = "0.2.4"
|
||||
@@ -2379,6 +2419,26 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue-sys"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kuchikiki"
|
||||
version = "0.8.8-speedreader"
|
||||
@@ -2603,6 +2663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
@@ -2708,6 +2769,46 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "7.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"filetime",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio",
|
||||
"notify-types",
|
||||
"walkdir",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-debouncer-mini"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aaa5a66d07ed97dce782be94dcf5ab4d1b457f4243f7566c7557f15cabc8c799"
|
||||
dependencies = [
|
||||
"log",
|
||||
"notify",
|
||||
"notify-types",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-types"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174"
|
||||
dependencies = [
|
||||
"instant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
|
||||
@@ -34,6 +34,8 @@ once_cell = "1.19"
|
||||
urlencoding = "2.1"
|
||||
qrcode = "0.14"
|
||||
image = "0.25"
|
||||
notify = "7.0"
|
||||
notify-debouncer-mini = "0.5"
|
||||
|
||||
[profile.dev]
|
||||
incremental = true
|
||||
|
||||
455
packages/editor-app/src-tauri/src/commands/build.rs
Normal file
455
packages/editor-app/src-tauri/src/commands/build.rs
Normal file
@@ -0,0 +1,455 @@
|
||||
//! Build related commands.
|
||||
//! 构建相关命令。
|
||||
//!
|
||||
//! Provides file operations and compilation for build pipelines.
|
||||
//! 为构建管线提供文件操作和编译功能。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Build progress event.
|
||||
/// 构建进度事件。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildProgressEvent {
|
||||
/// Progress percentage (0-100) | 进度百分比
|
||||
pub progress: u32,
|
||||
/// Current step message | 当前步骤消息
|
||||
pub message: String,
|
||||
/// Current step index | 当前步骤索引
|
||||
pub current_step: u32,
|
||||
/// Total steps | 总步骤数
|
||||
pub total_steps: u32,
|
||||
}
|
||||
|
||||
/// Clean and recreate output directory.
|
||||
/// 清理并重建输出目录。
|
||||
#[tauri::command]
|
||||
pub async fn prepare_build_directory(output_path: String) -> Result<(), String> {
|
||||
let path = Path::new(&output_path);
|
||||
|
||||
// Remove existing directory if exists | 如果存在则删除现有目录
|
||||
if path.exists() {
|
||||
fs::remove_dir_all(path)
|
||||
.map_err(|e| format!("Failed to clean output directory | 清理输出目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// Create fresh directory | 创建新目录
|
||||
fs::create_dir_all(path)
|
||||
.map_err(|e| format!("Failed to create output directory | 创建输出目录失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy directory recursively.
|
||||
/// 递归复制目录。
|
||||
#[tauri::command]
|
||||
pub async fn copy_directory(
|
||||
src: String,
|
||||
dst: String,
|
||||
patterns: Option<Vec<String>>,
|
||||
) -> Result<u32, String> {
|
||||
let src_path = Path::new(&src);
|
||||
let dst_path = Path::new(&dst);
|
||||
|
||||
if !src_path.exists() {
|
||||
return Err(format!("Source directory does not exist | 源目录不存在: {}", src));
|
||||
}
|
||||
|
||||
// Create destination directory | 创建目标目录
|
||||
fs::create_dir_all(dst_path)
|
||||
.map_err(|e| format!("Failed to create destination directory | 创建目标目录失败: {}", e))?;
|
||||
|
||||
let mut copied_count = 0u32;
|
||||
|
||||
// Recursively copy | 递归复制
|
||||
copy_dir_recursive(src_path, dst_path, &patterns, &mut copied_count)?;
|
||||
|
||||
Ok(copied_count)
|
||||
}
|
||||
|
||||
/// Helper function to copy directory recursively.
|
||||
/// 递归复制目录的辅助函数。
|
||||
fn copy_dir_recursive(
|
||||
src: &Path,
|
||||
dst: &Path,
|
||||
patterns: &Option<Vec<String>>,
|
||||
count: &mut u32,
|
||||
) -> Result<(), String> {
|
||||
for entry in fs::read_dir(src)
|
||||
.map_err(|e| format!("Failed to read directory | 读取目录失败: {}", e))?
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry | 读取条目失败: {}", e))?;
|
||||
let src_path = entry.path();
|
||||
let file_name = entry.file_name();
|
||||
let dst_path = dst.join(&file_name);
|
||||
|
||||
if src_path.is_dir() {
|
||||
// Skip hidden directories | 跳过隐藏目录
|
||||
if file_name.to_string_lossy().starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
fs::create_dir_all(&dst_path)
|
||||
.map_err(|e| format!("Failed to create directory | 创建目录失败: {}", e))?;
|
||||
copy_dir_recursive(&src_path, &dst_path, patterns, count)?;
|
||||
} else {
|
||||
// Check if file matches patterns | 检查文件是否匹配模式
|
||||
if let Some(ref pats) = patterns {
|
||||
let file_name_str = file_name.to_string_lossy();
|
||||
let matches = pats.iter().any(|p| {
|
||||
if p.starts_with("*.") {
|
||||
let ext = &p[2..];
|
||||
file_name_str.ends_with(&format!(".{}", ext))
|
||||
} else {
|
||||
file_name_str.contains(p)
|
||||
}
|
||||
});
|
||||
|
||||
if !matches {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
fs::copy(&src_path, &dst_path)
|
||||
.map_err(|e| format!("Failed to copy file | 复制文件失败: {} -> {}: {}",
|
||||
src_path.display(), dst_path.display(), e))?;
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bundle options for esbuild.
|
||||
/// esbuild 打包选项。
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BundleOptions {
|
||||
/// Entry files | 入口文件
|
||||
pub entry_points: Vec<String>,
|
||||
/// Output directory | 输出目录
|
||||
pub output_dir: String,
|
||||
/// Output format (esm or iife) | 输出格式
|
||||
pub format: String,
|
||||
/// Bundle name | 打包名称
|
||||
pub bundle_name: String,
|
||||
/// Whether to minify | 是否压缩
|
||||
pub minify: bool,
|
||||
/// Whether to generate source map | 是否生成 source map
|
||||
pub source_map: bool,
|
||||
/// External dependencies | 外部依赖
|
||||
pub external: Vec<String>,
|
||||
/// Project root for resolving imports | 项目根目录
|
||||
pub project_root: String,
|
||||
/// Define replacements | 宏定义替换
|
||||
pub define: Option<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// Bundle result.
|
||||
/// 打包结果。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BundleResult {
|
||||
/// Whether bundling succeeded | 是否打包成功
|
||||
pub success: bool,
|
||||
/// Output file path | 输出文件路径
|
||||
pub output_file: Option<String>,
|
||||
/// Output file size in bytes | 输出文件大小(字节)
|
||||
pub output_size: Option<u64>,
|
||||
/// Error message if failed | 失败时的错误信息
|
||||
pub error: Option<String>,
|
||||
/// Warnings | 警告
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Bundle JavaScript/TypeScript files using esbuild.
|
||||
/// 使用 esbuild 打包 JavaScript/TypeScript 文件。
|
||||
#[tauri::command]
|
||||
pub async fn bundle_scripts(options: BundleOptions) -> Result<BundleResult, String> {
|
||||
let esbuild_path = find_esbuild(&options.project_root)?;
|
||||
|
||||
// Build output file path | 构建输出文件路径
|
||||
let output_file = Path::new(&options.output_dir)
|
||||
.join(&options.bundle_name)
|
||||
.with_extension("js");
|
||||
|
||||
// Ensure output directory exists | 确保输出目录存在
|
||||
if let Some(parent) = output_file.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create output directory | 创建输出目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// Build esbuild arguments | 构建 esbuild 参数
|
||||
let mut args: Vec<String> = options.entry_points.clone();
|
||||
|
||||
args.push("--bundle".to_string());
|
||||
args.push(format!("--outfile={}", output_file.display()));
|
||||
args.push(format!("--format={}", options.format));
|
||||
args.push("--platform=browser".to_string());
|
||||
args.push("--target=es2020".to_string());
|
||||
|
||||
if options.source_map {
|
||||
args.push("--sourcemap".to_string());
|
||||
}
|
||||
|
||||
if options.minify {
|
||||
args.push("--minify".to_string());
|
||||
}
|
||||
|
||||
for external in &options.external {
|
||||
args.push(format!("--external:{}", external));
|
||||
}
|
||||
|
||||
// Add define replacements | 添加宏定义替换
|
||||
if let Some(ref defines) = options.define {
|
||||
for (key, value) in defines {
|
||||
args.push(format!("--define:{}={}", key, value));
|
||||
}
|
||||
}
|
||||
|
||||
// Run esbuild | 运行 esbuild
|
||||
let output = Command::new(&esbuild_path)
|
||||
.args(&args)
|
||||
.current_dir(&options.project_root)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run esbuild | 运行 esbuild 失败: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
// Get output file size | 获取输出文件大小
|
||||
let output_size = fs::metadata(&output_file)
|
||||
.map(|m| m.len())
|
||||
.ok();
|
||||
|
||||
// Parse warnings from stderr | 从 stderr 解析警告
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let warnings: Vec<String> = stderr
|
||||
.lines()
|
||||
.filter(|l| l.contains("warning"))
|
||||
.map(|l| l.to_string())
|
||||
.collect();
|
||||
|
||||
Ok(BundleResult {
|
||||
success: true,
|
||||
output_file: Some(output_file.to_string_lossy().to_string()),
|
||||
output_size,
|
||||
error: None,
|
||||
warnings,
|
||||
})
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
Ok(BundleResult {
|
||||
success: false,
|
||||
output_file: None,
|
||||
output_size: None,
|
||||
error: Some(stderr.to_string()),
|
||||
warnings: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate HTML file from template.
|
||||
/// 从模板生成 HTML 文件。
|
||||
#[tauri::command]
|
||||
pub async fn generate_html(
|
||||
output_path: String,
|
||||
title: String,
|
||||
scripts: Vec<String>,
|
||||
body_content: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let scripts_html: String = scripts
|
||||
.iter()
|
||||
.map(|s| format!(r#" <script src="{}"></script>"#, s))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let body = body_content.unwrap_or_else(|| {
|
||||
r#" <canvas id="game-canvas" style="width: 100%; height: 100%;"></canvas>"#.to_string()
|
||||
});
|
||||
|
||||
let html = format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{}</title>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
html, body {{ width: 100%; height: 100%; overflow: hidden; background: #000; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{}
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
title, body, scripts_html
|
||||
);
|
||||
|
||||
// Ensure parent directory exists | 确保父目录存在
|
||||
let path = Path::new(&output_path);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory | 创建目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
fs::write(&output_path, html)
|
||||
.map_err(|e| format!("Failed to write HTML file | 写入 HTML 文件失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get file size.
|
||||
/// 获取文件大小。
|
||||
#[tauri::command]
|
||||
pub async fn get_file_size(file_path: String) -> Result<u64, String> {
|
||||
fs::metadata(&file_path)
|
||||
.map(|m| m.len())
|
||||
.map_err(|e| format!("Failed to get file size | 获取文件大小失败: {}", e))
|
||||
}
|
||||
|
||||
/// Get directory size recursively.
|
||||
/// 递归获取目录大小。
|
||||
#[tauri::command]
|
||||
pub async fn get_directory_size(dir_path: String) -> Result<u64, String> {
|
||||
let path = Path::new(&dir_path);
|
||||
if !path.exists() {
|
||||
return Err(format!("Directory does not exist | 目录不存在: {}", dir_path));
|
||||
}
|
||||
|
||||
calculate_dir_size(path)
|
||||
}
|
||||
|
||||
/// Helper to calculate directory size.
|
||||
/// 计算目录大小的辅助函数。
|
||||
fn calculate_dir_size(path: &Path) -> Result<u64, String> {
|
||||
let mut total_size = 0u64;
|
||||
|
||||
for entry in fs::read_dir(path)
|
||||
.map_err(|e| format!("Failed to read directory | 读取目录失败: {}", e))?
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry | 读取条目失败: {}", e))?;
|
||||
let entry_path = entry.path();
|
||||
|
||||
if entry_path.is_dir() {
|
||||
total_size += calculate_dir_size(&entry_path)?;
|
||||
} else {
|
||||
total_size += fs::metadata(&entry_path)
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total_size)
|
||||
}
|
||||
|
||||
/// Find esbuild executable.
|
||||
/// 查找 esbuild 可执行文件。
|
||||
fn find_esbuild(project_root: &str) -> Result<String, String> {
|
||||
let project_path = Path::new(project_root);
|
||||
|
||||
// Try local node_modules first | 首先尝试本地 node_modules
|
||||
let local_esbuild = if cfg!(windows) {
|
||||
project_path.join("node_modules/.bin/esbuild.cmd")
|
||||
} else {
|
||||
project_path.join("node_modules/.bin/esbuild")
|
||||
};
|
||||
|
||||
if local_esbuild.exists() {
|
||||
return Ok(local_esbuild.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
// Try global esbuild | 尝试全局 esbuild
|
||||
let global_esbuild = if cfg!(windows) { "esbuild.cmd" } else { "esbuild" };
|
||||
|
||||
let check = Command::new(global_esbuild)
|
||||
.arg("--version")
|
||||
.output();
|
||||
|
||||
match check {
|
||||
Ok(output) if output.status.success() => Ok(global_esbuild.to_string()),
|
||||
_ => Err("esbuild not found | 未找到 esbuild".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Write JSON file.
|
||||
/// 写入 JSON 文件。
|
||||
#[tauri::command]
|
||||
pub async fn write_json_file(file_path: String, content: String) -> Result<(), String> {
|
||||
let path = Path::new(&file_path);
|
||||
|
||||
// Ensure parent directory exists | 确保父目录存在
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory | 创建目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
fs::write(&file_path, content)
|
||||
.map_err(|e| format!("Failed to write JSON file | 写入 JSON 文件失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List files in directory with extension filter.
|
||||
/// 列出目录中指定扩展名的文件。
|
||||
#[tauri::command]
|
||||
pub async fn list_files_by_extension(
|
||||
dir_path: String,
|
||||
extensions: Vec<String>,
|
||||
recursive: bool,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let path = Path::new(&dir_path);
|
||||
if !path.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
list_files_recursive(path, &extensions, recursive, &mut files)?;
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Helper to list files recursively.
|
||||
/// 递归列出文件的辅助函数。
|
||||
fn list_files_recursive(
|
||||
path: &Path,
|
||||
extensions: &[String],
|
||||
recursive: bool,
|
||||
files: &mut Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
for entry in fs::read_dir(path)
|
||||
.map_err(|e| format!("Failed to read directory | 读取目录失败: {}", e))?
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry | 读取条目失败: {}", e))?;
|
||||
let entry_path = entry.path();
|
||||
|
||||
if entry_path.is_dir() {
|
||||
if recursive {
|
||||
list_files_recursive(&entry_path, extensions, recursive, files)?;
|
||||
}
|
||||
} else if let Some(ext) = entry_path.extension() {
|
||||
let ext_str = ext.to_string_lossy().to_lowercase();
|
||||
if extensions.iter().any(|e| e.to_lowercase() == ext_str) {
|
||||
files.push(entry_path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read binary file and return as base64.
|
||||
/// 读取二进制文件并返回 base64 编码。
|
||||
#[tauri::command]
|
||||
pub async fn read_binary_file_as_base64(path: String) -> Result<String, String> {
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
|
||||
let bytes = fs::read(&path)
|
||||
.map_err(|e| format!("Failed to read binary file | 读取二进制文件失败: {}", e))?;
|
||||
|
||||
Ok(STANDARD.encode(&bytes))
|
||||
}
|
||||
389
packages/editor-app/src-tauri/src/commands/compiler.rs
Normal file
389
packages/editor-app/src-tauri/src/commands/compiler.rs
Normal file
@@ -0,0 +1,389 @@
|
||||
//! User code compilation commands.
|
||||
//! 用户代码编译命令。
|
||||
//!
|
||||
//! Provides TypeScript compilation using esbuild for user scripts.
|
||||
//! 使用 esbuild 为用户脚本提供 TypeScript 编译。
|
||||
|
||||
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher, Event, EventKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::mpsc::channel;
|
||||
use std::time::Duration;
|
||||
use tauri::{command, AppHandle, Emitter, State};
|
||||
use crate::state::ScriptWatcherState;
|
||||
|
||||
/// Compilation options.
|
||||
/// 编译选项。
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompileOptions {
|
||||
/// Entry file path | 入口文件路径
|
||||
pub entry_path: String,
|
||||
/// Output file path | 输出文件路径
|
||||
pub output_path: String,
|
||||
/// Output format (esm or iife) | 输出格式
|
||||
pub format: String,
|
||||
/// Whether to generate source map | 是否生成 source map
|
||||
pub source_map: bool,
|
||||
/// Whether to minify | 是否压缩
|
||||
pub minify: bool,
|
||||
/// External dependencies | 外部依赖
|
||||
pub external: Vec<String>,
|
||||
/// Project root for resolving imports | 项目根目录用于解析导入
|
||||
pub project_root: String,
|
||||
}
|
||||
|
||||
/// Compilation error.
|
||||
/// 编译错误。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompileError {
|
||||
/// Error message | 错误信息
|
||||
pub message: String,
|
||||
/// File path | 文件路径
|
||||
pub file: Option<String>,
|
||||
/// Line number | 行号
|
||||
pub line: Option<u32>,
|
||||
/// Column number | 列号
|
||||
pub column: Option<u32>,
|
||||
}
|
||||
|
||||
/// Compilation result.
|
||||
/// 编译结果。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompileResult {
|
||||
/// Whether compilation succeeded | 是否编译成功
|
||||
pub success: bool,
|
||||
/// Compilation errors | 编译错误
|
||||
pub errors: Vec<CompileError>,
|
||||
/// Output file path (if successful) | 输出文件路径(如果成功)
|
||||
pub output_path: Option<String>,
|
||||
}
|
||||
|
||||
/// File change event sent to frontend.
|
||||
/// 发送到前端的文件变更事件。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileChangeEvent {
|
||||
/// Type of change: "create", "modify", "remove" | 变更类型
|
||||
pub change_type: String,
|
||||
/// File paths that changed | 发生变更的文件路径
|
||||
pub paths: Vec<String>,
|
||||
}
|
||||
|
||||
/// Compile TypeScript using esbuild.
|
||||
/// 使用 esbuild 编译 TypeScript。
|
||||
///
|
||||
/// # Arguments | 参数
|
||||
/// * `options` - Compilation options | 编译选项
|
||||
///
|
||||
/// # Returns | 返回
|
||||
/// Compilation result | 编译结果
|
||||
#[command]
|
||||
pub async fn compile_typescript(options: CompileOptions) -> Result<CompileResult, String> {
|
||||
// Check if esbuild is available | 检查 esbuild 是否可用
|
||||
let esbuild_path = find_esbuild(&options.project_root)?;
|
||||
|
||||
// Build esbuild arguments | 构建 esbuild 参数
|
||||
let mut args = vec![
|
||||
options.entry_path.clone(),
|
||||
"--bundle".to_string(),
|
||||
format!("--outfile={}", options.output_path),
|
||||
format!("--format={}", options.format),
|
||||
"--platform=browser".to_string(),
|
||||
"--target=es2020".to_string(),
|
||||
];
|
||||
|
||||
// Add source map option | 添加 source map 选项
|
||||
if options.source_map {
|
||||
args.push("--sourcemap".to_string());
|
||||
}
|
||||
|
||||
// Add minify option | 添加压缩选项
|
||||
if options.minify {
|
||||
args.push("--minify".to_string());
|
||||
}
|
||||
|
||||
// Add external dependencies | 添加外部依赖
|
||||
for external in &options.external {
|
||||
args.push(format!("--external:{}", external));
|
||||
}
|
||||
|
||||
// Run esbuild | 运行 esbuild
|
||||
let output = Command::new(&esbuild_path)
|
||||
.args(&args)
|
||||
.current_dir(&options.project_root)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run esbuild | 运行 esbuild 失败: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(CompileResult {
|
||||
success: true,
|
||||
errors: vec![],
|
||||
output_path: Some(options.output_path),
|
||||
})
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let errors = parse_esbuild_errors(&stderr);
|
||||
|
||||
Ok(CompileResult {
|
||||
success: false,
|
||||
errors,
|
||||
output_path: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch for file changes in scripts directory.
|
||||
/// 监视脚本目录中的文件变更。
|
||||
///
|
||||
/// Emits "user-code:file-changed" events when files change.
|
||||
/// 当文件发生变更时触发 "user-code:file-changed" 事件。
|
||||
#[command]
|
||||
pub async fn watch_scripts(
|
||||
app: AppHandle,
|
||||
watcher_state: State<'_, ScriptWatcherState>,
|
||||
project_path: String,
|
||||
scripts_dir: String,
|
||||
) -> Result<(), String> {
|
||||
let watch_path = Path::new(&project_path).join(&scripts_dir);
|
||||
|
||||
if !watch_path.exists() {
|
||||
return Err(format!(
|
||||
"Scripts directory does not exist | 脚本目录不存在: {}",
|
||||
watch_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Check if already watching this project | 检查是否已在监视此项目
|
||||
{
|
||||
let watchers = watcher_state.watchers.lock().await;
|
||||
if watchers.contains_key(&project_path) {
|
||||
println!("[UserCode] Already watching: {}", project_path);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Create a channel for shutdown signal | 创建关闭信号通道
|
||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
|
||||
// Clone values for the spawned task | 克隆值以供任务使用
|
||||
let project_path_clone = project_path.clone();
|
||||
let watch_path_clone = watch_path.clone();
|
||||
let app_clone = app.clone();
|
||||
|
||||
// Spawn file watcher task | 启动文件监视任务
|
||||
tokio::spawn(async move {
|
||||
// Create notify watcher | 创建 notify 监视器
|
||||
let (tx, rx) = channel();
|
||||
|
||||
let mut watcher = match RecommendedWatcher::new(
|
||||
move |res: Result<Event, notify::Error>| {
|
||||
if let Ok(event) = res {
|
||||
let _ = tx.send(event);
|
||||
}
|
||||
},
|
||||
Config::default().with_poll_interval(Duration::from_millis(500)),
|
||||
) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
eprintln!("[UserCode] Failed to create watcher: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Start watching | 开始监视
|
||||
if let Err(e) = watcher.watch(&watch_path_clone, RecursiveMode::Recursive) {
|
||||
eprintln!("[UserCode] Failed to watch path: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("[UserCode] Started watching: {}", watch_path_clone.display());
|
||||
|
||||
// Event loop | 事件循环
|
||||
loop {
|
||||
// Check for shutdown | 检查关闭信号
|
||||
if shutdown_rx.try_recv().is_ok() {
|
||||
println!("[UserCode] Stopping watcher for: {}", project_path_clone);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for file events with timeout | 带超时检查文件事件
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(event) => {
|
||||
// Filter for TypeScript/JavaScript files | 过滤 TypeScript/JavaScript 文件
|
||||
let ts_paths: Vec<String> = event
|
||||
.paths
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
matches!(ext, "ts" | "tsx" | "js" | "jsx")
|
||||
})
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
if !ts_paths.is_empty() {
|
||||
let change_type = match event.kind {
|
||||
EventKind::Create(_) => "create",
|
||||
EventKind::Modify(_) => "modify",
|
||||
EventKind::Remove(_) => "remove",
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let file_event = FileChangeEvent {
|
||||
change_type: change_type.to_string(),
|
||||
paths: ts_paths,
|
||||
};
|
||||
|
||||
println!("[UserCode] File change detected: {:?}", file_event);
|
||||
|
||||
// Emit event to frontend | 向前端发送事件
|
||||
if let Err(e) = app_clone.emit("user-code:file-changed", file_event) {
|
||||
eprintln!("[UserCode] Failed to emit event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
// No events, continue | 无事件,继续
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
println!("[UserCode] Watcher channel disconnected");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Store watcher handle | 存储监视器句柄
|
||||
{
|
||||
let mut watchers = watcher_state.watchers.lock().await;
|
||||
watchers.insert(
|
||||
project_path.clone(),
|
||||
crate::state::WatcherHandle { shutdown_tx },
|
||||
);
|
||||
}
|
||||
|
||||
println!("[UserCode] Watch scripts started for: {}/{}", project_path, scripts_dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop watching for file changes.
|
||||
/// 停止监视文件变更。
|
||||
#[command]
|
||||
pub async fn stop_watch_scripts(
|
||||
watcher_state: State<'_, ScriptWatcherState>,
|
||||
project_path: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let mut watchers = watcher_state.watchers.lock().await;
|
||||
|
||||
match project_path {
|
||||
Some(path) => {
|
||||
// Stop specific watcher | 停止特定监视器
|
||||
if let Some(handle) = watchers.remove(&path) {
|
||||
let _ = handle.shutdown_tx.send(());
|
||||
println!("[UserCode] Stopped watching: {}", path);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Stop all watchers | 停止所有监视器
|
||||
for (path, handle) in watchers.drain() {
|
||||
let _ = handle.shutdown_tx.send(());
|
||||
println!("[UserCode] Stopped watching: {}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find esbuild executable path.
|
||||
/// 查找 esbuild 可执行文件路径。
|
||||
fn find_esbuild(project_root: &str) -> Result<String, String> {
|
||||
let project_path = Path::new(project_root);
|
||||
|
||||
// Try local node_modules first | 首先尝试本地 node_modules
|
||||
let local_esbuild = if cfg!(windows) {
|
||||
project_path.join("node_modules/.bin/esbuild.cmd")
|
||||
} else {
|
||||
project_path.join("node_modules/.bin/esbuild")
|
||||
};
|
||||
|
||||
if local_esbuild.exists() {
|
||||
return Ok(local_esbuild.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
// Try global esbuild | 尝试全局 esbuild
|
||||
let global_esbuild = if cfg!(windows) { "esbuild.cmd" } else { "esbuild" };
|
||||
|
||||
// Check if global esbuild exists | 检查全局 esbuild 是否存在
|
||||
let check = Command::new(global_esbuild)
|
||||
.arg("--version")
|
||||
.output();
|
||||
|
||||
match check {
|
||||
Ok(output) if output.status.success() => Ok(global_esbuild.to_string()),
|
||||
_ => Err("esbuild not found. Please install esbuild: npm install -g esbuild | 未找到 esbuild,请安装: npm install -g esbuild".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse esbuild error output.
|
||||
/// 解析 esbuild 错误输出。
|
||||
fn parse_esbuild_errors(stderr: &str) -> Vec<CompileError> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// Simple error parsing - esbuild outputs errors in a specific format
|
||||
// 简单的错误解析 - esbuild 以特定格式输出错误
|
||||
for line in stderr.lines() {
|
||||
if line.contains("error:") || line.contains("Error:") {
|
||||
// Try to parse file:line:column format | 尝试解析 file:line:column 格式
|
||||
let parts: Vec<&str> = line.splitn(2, ": ").collect();
|
||||
|
||||
if parts.len() == 2 {
|
||||
let location = parts[0];
|
||||
let message = parts[1].to_string();
|
||||
|
||||
// Parse location (file:line:column) | 解析位置
|
||||
let loc_parts: Vec<&str> = location.split(':').collect();
|
||||
|
||||
let (file, line_num, column) = if loc_parts.len() >= 3 {
|
||||
(
|
||||
Some(loc_parts[0].to_string()),
|
||||
loc_parts[1].parse().ok(),
|
||||
loc_parts[2].parse().ok(),
|
||||
)
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
|
||||
errors.push(CompileError {
|
||||
message,
|
||||
file,
|
||||
line: line_num,
|
||||
column,
|
||||
});
|
||||
} else {
|
||||
errors.push(CompileError {
|
||||
message: line.to_string(),
|
||||
file: None,
|
||||
line: None,
|
||||
column: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific errors found, add the whole stderr as one error
|
||||
// 如果没有找到特定错误,将整个 stderr 作为一个错误
|
||||
if errors.is_empty() && !stderr.trim().is_empty() {
|
||||
errors.push(CompileError {
|
||||
message: stderr.to_string(),
|
||||
file: None,
|
||||
line: None,
|
||||
column: None,
|
||||
});
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
@@ -1,17 +1,25 @@
|
||||
//! Command modules
|
||||
//! Command modules.
|
||||
//! 命令模块。
|
||||
//!
|
||||
//! All Tauri commands organized by domain.
|
||||
//! 所有按领域组织的 Tauri 命令。
|
||||
|
||||
pub mod build;
|
||||
pub mod compiler;
|
||||
pub mod dialog;
|
||||
pub mod file_system;
|
||||
pub mod modules;
|
||||
pub mod plugin;
|
||||
pub mod profiler;
|
||||
pub mod project;
|
||||
pub mod system;
|
||||
|
||||
// Re-export all commands for convenience
|
||||
// Re-export all commands for convenience | 重新导出所有命令以方便使用
|
||||
pub use build::*;
|
||||
pub use compiler::*;
|
||||
pub use dialog::*;
|
||||
pub use file_system::*;
|
||||
pub use modules::*;
|
||||
pub use plugin::*;
|
||||
pub use profiler::*;
|
||||
pub use project::*;
|
||||
|
||||
175
packages/editor-app/src-tauri/src/commands/modules.rs
Normal file
175
packages/editor-app/src-tauri/src/commands/modules.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! Engine Module Commands
|
||||
//! 引擎模块命令
|
||||
//!
|
||||
//! Commands for reading engine module configurations.
|
||||
//! 用于读取引擎模块配置的命令。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tauri::{command, AppHandle};
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
use tauri::Manager;
|
||||
|
||||
/// Module index structure.
|
||||
/// 模块索引结构。
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct ModuleIndex {
|
||||
pub version: String,
|
||||
#[serde(rename = "generatedAt")]
|
||||
pub generated_at: String,
|
||||
pub modules: Vec<ModuleIndexEntry>,
|
||||
}
|
||||
|
||||
/// Module index entry.
|
||||
/// 模块索引条目。
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct ModuleIndexEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "displayName")]
|
||||
pub display_name: String,
|
||||
#[serde(rename = "hasRuntime")]
|
||||
pub has_runtime: bool,
|
||||
#[serde(rename = "editorPackage")]
|
||||
pub editor_package: Option<String>,
|
||||
#[serde(rename = "isCore")]
|
||||
pub is_core: bool,
|
||||
pub category: String,
|
||||
/// JS bundle size in bytes | JS 包大小(字节)
|
||||
#[serde(rename = "jsSize")]
|
||||
pub js_size: Option<u64>,
|
||||
/// Whether this module requires WASM | 是否需要 WASM
|
||||
#[serde(rename = "requiresWasm")]
|
||||
pub requires_wasm: Option<bool>,
|
||||
/// WASM file size in bytes | WASM 文件大小(字节)
|
||||
#[serde(rename = "wasmSize")]
|
||||
pub wasm_size: Option<u64>,
|
||||
}
|
||||
|
||||
/// Get the engine modules directory path.
|
||||
/// 获取引擎模块目录路径。
|
||||
///
|
||||
/// Uses compile-time CARGO_MANIFEST_DIR in dev mode to locate dist/engine.
|
||||
/// 在开发模式下使用编译时的 CARGO_MANIFEST_DIR 来定位 dist/engine。
|
||||
#[allow(unused_variables)]
|
||||
fn get_engine_modules_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
// In development mode, use compile-time path
|
||||
// 在开发模式下,使用编译时路径
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
// CARGO_MANIFEST_DIR is set at compile time, pointing to src-tauri
|
||||
// CARGO_MANIFEST_DIR 在编译时设置,指向 src-tauri
|
||||
let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.map(|p| p.join("dist/engine"))
|
||||
.unwrap_or_else(|| PathBuf::from("dist/engine"));
|
||||
|
||||
if dev_path.exists() {
|
||||
println!("[modules] Using dev path: {:?}", dev_path);
|
||||
return Ok(dev_path);
|
||||
}
|
||||
|
||||
// Fallback: try current working directory
|
||||
// 回退:尝试当前工作目录
|
||||
let cwd_path = std::env::current_dir()
|
||||
.map(|p| p.join("dist/engine"))
|
||||
.unwrap_or_else(|_| PathBuf::from("dist/engine"));
|
||||
|
||||
if cwd_path.exists() {
|
||||
println!("[modules] Using cwd path: {:?}", cwd_path);
|
||||
return Ok(cwd_path);
|
||||
}
|
||||
|
||||
return Err(format!(
|
||||
"Engine modules directory not found in dev mode. Tried: {:?}, {:?}. Run 'pnpm copy-modules' first.",
|
||||
dev_path, cwd_path
|
||||
));
|
||||
}
|
||||
|
||||
// Production: use resource directory
|
||||
// 生产环境:使用资源目录
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
let resource_path = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
.map_err(|e| format!("Failed to get resource dir: {}", e))?;
|
||||
|
||||
let prod_path = resource_path.join("engine");
|
||||
|
||||
if prod_path.exists() {
|
||||
return Ok(prod_path);
|
||||
}
|
||||
|
||||
// Fallback: try exe directory
|
||||
// 回退:尝试可执行文件目录
|
||||
let exe_path = std::env::current_exe()
|
||||
.map_err(|e| format!("Failed to get exe path: {}", e))?;
|
||||
let exe_dir = exe_path.parent()
|
||||
.ok_or("Failed to get exe directory")?;
|
||||
|
||||
let exe_engine_path = exe_dir.join("engine");
|
||||
if exe_engine_path.exists() {
|
||||
return Ok(exe_engine_path);
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Engine modules directory not found. Tried: {:?}, {:?}",
|
||||
prod_path, exe_engine_path
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the engine modules index.
|
||||
/// 读取引擎模块索引。
|
||||
#[command]
|
||||
pub async fn read_engine_modules_index(app: AppHandle) -> Result<ModuleIndex, String> {
|
||||
println!("[modules] read_engine_modules_index called");
|
||||
let engine_path = get_engine_modules_path(&app)?;
|
||||
println!("[modules] engine_path: {:?}", engine_path);
|
||||
let index_path = engine_path.join("index.json");
|
||||
|
||||
if !index_path.exists() {
|
||||
return Err(format!(
|
||||
"Module index not found at {:?}. Run 'pnpm copy-modules' first.",
|
||||
index_path
|
||||
));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&index_path)
|
||||
.map_err(|e| format!("Failed to read index.json: {}", e))?;
|
||||
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse index.json: {}", e))
|
||||
}
|
||||
|
||||
/// Read a specific module's manifest.
|
||||
/// 读取特定模块的清单。
|
||||
#[command]
|
||||
pub async fn read_module_manifest(app: AppHandle, module_id: String) -> Result<serde_json::Value, String> {
|
||||
let engine_path = get_engine_modules_path(&app)?;
|
||||
let manifest_path = engine_path.join(&module_id).join("module.json");
|
||||
|
||||
if !manifest_path.exists() {
|
||||
return Err(format!(
|
||||
"Module manifest not found for '{}' at {:?}",
|
||||
module_id, manifest_path
|
||||
));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&manifest_path)
|
||||
.map_err(|e| format!("Failed to read module.json for {}: {}", module_id, e))?;
|
||||
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse module.json for {}: {}", module_id, e))
|
||||
}
|
||||
|
||||
/// Get the base path to engine modules directory.
|
||||
/// 获取引擎模块目录的基础路径。
|
||||
#[command]
|
||||
pub async fn get_engine_modules_base_path(app: AppHandle) -> Result<String, String> {
|
||||
let path = get_engine_modules_path(&app)?;
|
||||
path.to_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "Failed to convert path to string".to_string())
|
||||
}
|
||||
@@ -12,14 +12,15 @@ use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::Manager;
|
||||
|
||||
use state::{ProfilerState, ProjectPaths};
|
||||
use state::{ProfilerState, ProjectPaths, ScriptWatcherState};
|
||||
|
||||
fn main() {
|
||||
// Initialize shared state
|
||||
// Initialize shared state | 初始化共享状态
|
||||
let project_paths: ProjectPaths = Arc::new(Mutex::new(HashMap::new()));
|
||||
let project_paths_for_protocol = Arc::clone(&project_paths);
|
||||
|
||||
let profiler_state = ProfilerState::new();
|
||||
let script_watcher_state = ScriptWatcherState::new();
|
||||
|
||||
// Build and run the Tauri application
|
||||
tauri::Builder::default()
|
||||
@@ -34,10 +35,11 @@ fn main() {
|
||||
.register_uri_scheme_protocol("project", move |_app, request| {
|
||||
handle_project_protocol(request, &project_paths_for_protocol)
|
||||
})
|
||||
// Setup application state
|
||||
// Setup application state | 设置应用状态
|
||||
.setup(move |app| {
|
||||
app.manage(project_paths);
|
||||
app.manage(profiler_state);
|
||||
app.manage(script_watcher_state);
|
||||
Ok(())
|
||||
})
|
||||
// Register all commands
|
||||
@@ -85,6 +87,24 @@ fn main() {
|
||||
commands::stop_local_server,
|
||||
commands::get_local_ip,
|
||||
commands::generate_qrcode,
|
||||
// User code compilation | 用户代码编译
|
||||
commands::compile_typescript,
|
||||
commands::watch_scripts,
|
||||
commands::stop_watch_scripts,
|
||||
// Build commands | 构建命令
|
||||
commands::prepare_build_directory,
|
||||
commands::copy_directory,
|
||||
commands::bundle_scripts,
|
||||
commands::generate_html,
|
||||
commands::get_file_size,
|
||||
commands::get_directory_size,
|
||||
commands::write_json_file,
|
||||
commands::list_files_by_extension,
|
||||
commands::read_binary_file_as_base64,
|
||||
// Engine modules | 引擎模块
|
||||
commands::read_engine_modules_index,
|
||||
commands::read_module_manifest,
|
||||
commands::get_engine_modules_base_path,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,17 +1,52 @@
|
||||
//! Application state definitions
|
||||
//! Application state definitions.
|
||||
//! 应用状态定义。
|
||||
//!
|
||||
//! Centralized state management for the Tauri application.
|
||||
//! Tauri 应用的集中状态管理。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use crate::profiler_ws::ProfilerServer;
|
||||
|
||||
/// Project paths state
|
||||
/// Project paths state.
|
||||
/// 项目路径状态。
|
||||
///
|
||||
/// Stores the current project path and other path-related information.
|
||||
/// 存储当前项目路径和其他路径相关信息。
|
||||
pub type ProjectPaths = Arc<Mutex<HashMap<String, String>>>;
|
||||
|
||||
/// Script watcher state.
|
||||
/// 脚本监视器状态。
|
||||
///
|
||||
/// Manages file watchers for hot reload functionality.
|
||||
/// 管理用于热重载功能的文件监视器。
|
||||
pub struct ScriptWatcherState {
|
||||
/// Active watchers keyed by project path | 按项目路径索引的活动监视器
|
||||
pub watchers: Arc<TokioMutex<HashMap<String, WatcherHandle>>>,
|
||||
}
|
||||
|
||||
/// Handle to a running file watcher.
|
||||
/// 正在运行的文件监视器句柄。
|
||||
pub struct WatcherHandle {
|
||||
/// Shutdown signal sender | 关闭信号发送器
|
||||
pub shutdown_tx: tokio::sync::oneshot::Sender<()>,
|
||||
}
|
||||
|
||||
impl ScriptWatcherState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
watchers: Arc::new(TokioMutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ScriptWatcherState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Profiler server state
|
||||
///
|
||||
/// Manages the lifecycle of the WebSocket profiler server.
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
ICompilerRegistry,
|
||||
InspectorRegistry,
|
||||
INotification,
|
||||
CommandManager
|
||||
CommandManager,
|
||||
BuildService
|
||||
} from '@esengine/editor-core';
|
||||
import type { IDialogExtended } from './services/TauriDialogService';
|
||||
import { GlobalBlackboardService } from '@esengine/behavior-tree';
|
||||
@@ -42,6 +43,7 @@ import { AboutDialog } from './components/AboutDialog';
|
||||
import { ErrorDialog } from './components/ErrorDialog';
|
||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
|
||||
import { BuildSettingsWindow } from './components/BuildSettingsWindow';
|
||||
import { ToastProvider, useToast } from './components/Toast';
|
||||
import { TitleBar } from './components/TitleBar';
|
||||
import { MainToolbar } from './components/MainToolbar';
|
||||
@@ -95,6 +97,7 @@ function App() {
|
||||
const [sceneManager, setSceneManager] = useState<SceneManagerService | null>(null);
|
||||
const [notification, setNotification] = useState<INotification | null>(null);
|
||||
const [dialog, setDialog] = useState<IDialogExtended | null>(null);
|
||||
const [buildService, setBuildService] = useState<BuildService | null>(null);
|
||||
const [commandManager] = useState(() => new CommandManager());
|
||||
const { t, locale, changeLocale } = useLocale();
|
||||
|
||||
@@ -117,6 +120,7 @@ function App() {
|
||||
showSettings, setShowSettings,
|
||||
showAbout, setShowAbout,
|
||||
showPluginGenerator, setShowPluginGenerator,
|
||||
showBuildSettings, setShowBuildSettings,
|
||||
errorDialog, setErrorDialog,
|
||||
confirmDialog, setConfirmDialog
|
||||
} = useDialogStore();
|
||||
@@ -285,6 +289,7 @@ function App() {
|
||||
setSceneManager(services.sceneManager);
|
||||
setNotification(services.notification);
|
||||
setDialog(services.dialog as IDialogExtended);
|
||||
setBuildService(services.buildService);
|
||||
setStatus(t('header.status.ready'));
|
||||
|
||||
// Check for updates on startup (after 3 seconds)
|
||||
@@ -768,7 +773,7 @@ function App() {
|
||||
let content: React.ReactNode;
|
||||
if (panelDesc.component) {
|
||||
const Component = panelDesc.component;
|
||||
content = <Component projectPath={currentProjectPath} />;
|
||||
content = <Component projectPath={currentProjectPath} locale={locale} />;
|
||||
} else if (panelDesc.render) {
|
||||
content = panelDesc.render();
|
||||
}
|
||||
@@ -883,6 +888,7 @@ function App() {
|
||||
onOpenAbout={handleOpenAbout}
|
||||
onCreatePlugin={handleCreatePlugin}
|
||||
onReloadPlugins={handleReloadPlugins}
|
||||
onOpenBuildSettings={() => setShowBuildSettings(true)}
|
||||
/>
|
||||
<MainToolbar
|
||||
locale={locale}
|
||||
@@ -971,6 +977,16 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBuildSettings && (
|
||||
<BuildSettingsWindow
|
||||
onClose={() => setShowBuildSettings(false)}
|
||||
projectPath={currentProjectPath || undefined}
|
||||
locale={locale}
|
||||
buildService={buildService || undefined}
|
||||
sceneManager={sceneManager || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{errorDialog && (
|
||||
<ErrorDialog
|
||||
title={errorDialog.title}
|
||||
|
||||
@@ -13,6 +13,7 @@ interface DialogState {
|
||||
showSettings: boolean;
|
||||
showAbout: boolean;
|
||||
showPluginGenerator: boolean;
|
||||
showBuildSettings: boolean;
|
||||
errorDialog: ErrorDialogData | null;
|
||||
confirmDialog: ConfirmDialogData | null;
|
||||
|
||||
@@ -22,6 +23,7 @@ interface DialogState {
|
||||
setShowSettings: (show: boolean) => void;
|
||||
setShowAbout: (show: boolean) => void;
|
||||
setShowPluginGenerator: (show: boolean) => void;
|
||||
setShowBuildSettings: (show: boolean) => void;
|
||||
setErrorDialog: (data: ErrorDialogData | null) => void;
|
||||
setConfirmDialog: (data: ConfirmDialogData | null) => void;
|
||||
closeAllDialogs: () => void;
|
||||
@@ -34,6 +36,7 @@ export const useDialogStore = create<DialogState>((set) => ({
|
||||
showSettings: false,
|
||||
showAbout: false,
|
||||
showPluginGenerator: false,
|
||||
showBuildSettings: false,
|
||||
errorDialog: null,
|
||||
confirmDialog: null,
|
||||
|
||||
@@ -43,6 +46,7 @@ export const useDialogStore = create<DialogState>((set) => ({
|
||||
setShowSettings: (show) => set({ showSettings: show }),
|
||||
setShowAbout: (show) => set({ showAbout: show }),
|
||||
setShowPluginGenerator: (show) => set({ showPluginGenerator: show }),
|
||||
setShowBuildSettings: (show) => set({ showBuildSettings: show }),
|
||||
setErrorDialog: (data) => set({ errorDialog: data }),
|
||||
setConfirmDialog: (data) => set({ confirmDialog: data }),
|
||||
|
||||
@@ -53,6 +57,7 @@ export const useDialogStore = create<DialogState>((set) => ({
|
||||
showSettings: false,
|
||||
showAbout: false,
|
||||
showPluginGenerator: false,
|
||||
showBuildSettings: false,
|
||||
errorDialog: null,
|
||||
confirmDialog: null
|
||||
})
|
||||
|
||||
@@ -13,8 +13,8 @@ import { GizmoPlugin } from '../../plugins/builtin/GizmoPlugin';
|
||||
import { SceneInspectorPlugin } from '../../plugins/builtin/SceneInspectorPlugin';
|
||||
import { ProfilerPlugin } from '../../plugins/builtin/ProfilerPlugin';
|
||||
import { EditorAppearancePlugin } from '../../plugins/builtin/EditorAppearancePlugin';
|
||||
import { PluginConfigPlugin } from '../../plugins/builtin/PluginConfigPlugin';
|
||||
import { ProjectSettingsPlugin } from '../../plugins/builtin/ProjectSettingsPlugin';
|
||||
// Note: PluginConfigPlugin removed - module management is now unified in ProjectSettingsPlugin
|
||||
|
||||
// 统一模块插件(从编辑器包导入完整插件,包含 runtime + editor)
|
||||
import { BehaviorTreePlugin } from '@esengine/behavior-tree-editor';
|
||||
@@ -22,6 +22,9 @@ import { Physics2DPlugin } from '@esengine/physics-rapier2d-editor';
|
||||
import { TilemapPlugin } from '@esengine/tilemap-editor';
|
||||
import { UIPlugin } from '@esengine/ui-editor';
|
||||
import { BlueprintPlugin } from '@esengine/blueprint-editor';
|
||||
import { MaterialPlugin } from '@esengine/material-editor';
|
||||
import { SpritePlugin } from '@esengine/sprite-editor';
|
||||
import { ShaderEditorPlugin } from '@esengine/shader-editor';
|
||||
|
||||
export class PluginInstaller {
|
||||
/**
|
||||
@@ -34,13 +37,12 @@ export class PluginInstaller {
|
||||
{ name: 'SceneInspectorPlugin', plugin: SceneInspectorPlugin },
|
||||
{ name: 'ProfilerPlugin', plugin: ProfilerPlugin },
|
||||
{ name: 'EditorAppearancePlugin', plugin: EditorAppearancePlugin },
|
||||
{ name: 'PluginConfigPlugin', plugin: PluginConfigPlugin },
|
||||
{ name: 'ProjectSettingsPlugin', plugin: ProjectSettingsPlugin },
|
||||
];
|
||||
|
||||
for (const { name, plugin } of builtinPlugins) {
|
||||
if (!plugin || !plugin.descriptor) {
|
||||
console.error(`[PluginInstaller] ${name} is invalid: missing descriptor`, plugin);
|
||||
if (!plugin || !plugin.manifest) {
|
||||
console.error(`[PluginInstaller] ${name} is invalid: missing manifest`, plugin);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -52,20 +54,23 @@ export class PluginInstaller {
|
||||
|
||||
// 统一模块插件(runtime + editor)
|
||||
const modulePlugins = [
|
||||
{ name: 'SpritePlugin', plugin: SpritePlugin },
|
||||
{ name: 'TilemapPlugin', plugin: TilemapPlugin },
|
||||
{ name: 'UIPlugin', plugin: UIPlugin },
|
||||
{ name: 'BehaviorTreePlugin', plugin: BehaviorTreePlugin },
|
||||
{ name: 'Physics2DPlugin', plugin: Physics2DPlugin },
|
||||
{ name: 'BlueprintPlugin', plugin: BlueprintPlugin },
|
||||
{ name: 'MaterialPlugin', plugin: MaterialPlugin },
|
||||
{ name: 'ShaderEditorPlugin', plugin: ShaderEditorPlugin },
|
||||
];
|
||||
|
||||
for (const { name, plugin } of modulePlugins) {
|
||||
if (!plugin || !plugin.descriptor) {
|
||||
console.error(`[PluginInstaller] ${name} is invalid: missing descriptor`, plugin);
|
||||
if (!plugin || !plugin.manifest) {
|
||||
console.error(`[PluginInstaller] ${name} is invalid: missing manifest`, plugin);
|
||||
continue;
|
||||
}
|
||||
// 详细日志,检查 editorModule 是否存在
|
||||
console.log(`[PluginInstaller] ${name}: descriptor.id=${plugin.descriptor.id}, hasRuntimeModule=${!!plugin.runtimeModule}, hasEditorModule=${!!plugin.editorModule}`);
|
||||
console.log(`[PluginInstaller] ${name}: manifest.id=${plugin.manifest.id}, hasRuntimeModule=${!!plugin.runtimeModule}, hasEditorModule=${!!plugin.editorModule}`);
|
||||
try {
|
||||
pluginManager.register(plugin);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Core, ComponentRegistry as CoreComponentRegistry } from '@esengine/ecs-framework';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
UIRegistry,
|
||||
MessageHub,
|
||||
@@ -27,8 +28,18 @@ import {
|
||||
IDialogService,
|
||||
IFileSystemService,
|
||||
CompilerRegistry,
|
||||
ICompilerRegistry
|
||||
ICompilerRegistry,
|
||||
IViewportService_ID,
|
||||
IPreviewSceneService,
|
||||
IEditorViewportServiceIdentifier,
|
||||
PreviewSceneService,
|
||||
EditorViewportService,
|
||||
BuildService,
|
||||
WebBuildPipeline,
|
||||
WeChatBuildPipeline,
|
||||
moduleRegistry
|
||||
} from '@esengine/editor-core';
|
||||
import { ViewportService } from '../../services/ViewportService';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
@@ -65,6 +76,8 @@ import {
|
||||
AnimationClipsFieldEditor
|
||||
} from '../../infrastructure/field-editors';
|
||||
import { TransformComponentInspector } from '../../components/inspectors/component-inspectors/TransformComponentInspector';
|
||||
import { buildFileSystem } from '../../services/BuildFileSystemService';
|
||||
import { TauriModuleFileSystem } from '../../services/TauriModuleFileSystem';
|
||||
|
||||
export interface EditorServices {
|
||||
uiRegistry: UIRegistry;
|
||||
@@ -90,6 +103,7 @@ export interface EditorServices {
|
||||
inspectorRegistry: InspectorRegistry;
|
||||
propertyRendererRegistry: PropertyRendererRegistry;
|
||||
fieldEditorRegistry: FieldEditorRegistry;
|
||||
buildService: BuildService;
|
||||
}
|
||||
|
||||
export class ServiceRegistry {
|
||||
@@ -172,6 +186,22 @@ export class ServiceRegistry {
|
||||
Core.services.registerInstance(IDialogService, dialog);
|
||||
Core.services.registerInstance(IFileSystemService, fileSystem);
|
||||
|
||||
// Register viewport service for editor panels
|
||||
// 注册视口服务供编辑器面板使用
|
||||
const viewportService = ViewportService.getInstance();
|
||||
Core.services.registerInstance(IViewportService_ID, viewportService);
|
||||
|
||||
// Register preview scene service for isolated preview scenes
|
||||
// 注册预览场景服务,用于隔离的预览场景
|
||||
const previewSceneService = PreviewSceneService.getInstance();
|
||||
Core.services.registerInstance(IPreviewSceneService, previewSceneService);
|
||||
|
||||
// Register editor viewport service for coordinating viewports with overlays
|
||||
// 注册编辑器视口服务,协调带有覆盖层的视口
|
||||
const editorViewportService = EditorViewportService.getInstance();
|
||||
editorViewportService.setViewportService(viewportService);
|
||||
Core.services.registerInstance(IEditorViewportServiceIdentifier, editorViewportService);
|
||||
|
||||
const inspectorRegistry = new InspectorRegistry();
|
||||
Core.services.registerInstance(InspectorRegistry, inspectorRegistry);
|
||||
Core.services.registerInstance(IInspectorRegistry, inspectorRegistry); // Symbol 注册用于跨包插件访问
|
||||
@@ -204,6 +234,43 @@ export class ServiceRegistry {
|
||||
// Register component inspectors
|
||||
componentInspectorRegistry.register(new TransformComponentInspector());
|
||||
|
||||
// 注册构建服务
|
||||
// Register build service
|
||||
const buildService = new BuildService();
|
||||
|
||||
// Register Web build pipeline with file system service
|
||||
// 注册 Web 构建管线并注入文件系统服务
|
||||
const webPipeline = new WebBuildPipeline();
|
||||
webPipeline.setFileSystem(buildFileSystem);
|
||||
|
||||
// Get engine modules path from Tauri backend
|
||||
// 从 Tauri 后端获取引擎模块路径
|
||||
invoke<string>('get_engine_modules_base_path').then(enginePath => {
|
||||
console.log('[ServiceRegistry] Engine modules path:', enginePath);
|
||||
webPipeline.setEngineModulesPath(enginePath);
|
||||
}).catch(err => {
|
||||
console.warn('[ServiceRegistry] Failed to get engine modules path:', err);
|
||||
});
|
||||
|
||||
buildService.register(webPipeline);
|
||||
|
||||
// Register WeChat build pipeline
|
||||
// 注册微信构建管线
|
||||
const wechatPipeline = new WeChatBuildPipeline();
|
||||
wechatPipeline.setFileSystem(buildFileSystem);
|
||||
buildService.register(wechatPipeline);
|
||||
|
||||
Core.services.registerInstance(BuildService, buildService);
|
||||
|
||||
// Initialize ModuleRegistry with Tauri file system
|
||||
// 使用 Tauri 文件系统初始化 ModuleRegistry
|
||||
// Engine modules are read via Tauri commands from local file system
|
||||
// 引擎模块通过 Tauri 命令从本地文件系统读取
|
||||
const tauriModuleFs = new TauriModuleFileSystem();
|
||||
moduleRegistry.initialize(tauriModuleFs, '/engine').catch(err => {
|
||||
console.warn('[ServiceRegistry] Failed to initialize ModuleRegistry:', err);
|
||||
});
|
||||
|
||||
// 注册默认场景模板 - 创建默认相机
|
||||
// Register default scene template - creates default camera
|
||||
this.registerDefaultSceneTemplate();
|
||||
@@ -231,7 +298,8 @@ export class ServiceRegistry {
|
||||
notification,
|
||||
inspectorRegistry,
|
||||
propertyRendererRegistry,
|
||||
fieldEditorRegistry
|
||||
fieldEditorRegistry,
|
||||
buildService
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
897
packages/editor-app/src/components/BuildSettingsPanel.tsx
Normal file
897
packages/editor-app/src/components/BuildSettingsPanel.tsx
Normal file
@@ -0,0 +1,897 @@
|
||||
/**
|
||||
* Build Settings Panel.
|
||||
* 构建设置面板。
|
||||
*
|
||||
* Provides build settings interface for managing platform builds,
|
||||
* scenes, and player settings.
|
||||
* 提供构建设置界面,用于管理平台构建、场景和玩家设置。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Monitor, Apple, Smartphone, Globe, Server, Gamepad2,
|
||||
Plus, Minus, ChevronDown, ChevronRight, Settings,
|
||||
Package, Loader2, CheckCircle, XCircle, AlertTriangle, X
|
||||
} from 'lucide-react';
|
||||
import type { BuildService, BuildProgress, BuildConfig, WebBuildConfig, WeChatBuildConfig, SceneManagerService } from '@esengine/editor-core';
|
||||
import { BuildPlatform, BuildStatus } from '@esengine/editor-core';
|
||||
import '../styles/BuildSettingsPanel.css';
|
||||
|
||||
// ==================== Types | 类型定义 ====================
|
||||
|
||||
/** Platform type | 平台类型 */
|
||||
type PlatformType =
|
||||
| 'windows'
|
||||
| 'macos'
|
||||
| 'linux'
|
||||
| 'android'
|
||||
| 'ios'
|
||||
| 'web'
|
||||
| 'wechat-minigame';
|
||||
|
||||
/** Build profile | 构建配置 */
|
||||
interface BuildProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: PlatformType;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/** Scene entry | 场景条目 */
|
||||
interface SceneEntry {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Platform configuration | 平台配置 */
|
||||
interface PlatformConfig {
|
||||
platform: PlatformType;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
/** Build settings | 构建设置 */
|
||||
interface BuildSettings {
|
||||
scenes: SceneEntry[];
|
||||
scriptingDefines: string[];
|
||||
companyName: string;
|
||||
productName: string;
|
||||
version: string;
|
||||
// Platform-specific | 平台特定
|
||||
developmentBuild: boolean;
|
||||
sourceMap: boolean;
|
||||
compressionMethod: 'Default' | 'LZ4' | 'LZ4HC';
|
||||
bundleModules: boolean;
|
||||
}
|
||||
|
||||
// ==================== Constants | 常量 ====================
|
||||
|
||||
const PLATFORMS: PlatformConfig[] = [
|
||||
{ platform: 'windows', label: 'Windows', icon: <Monitor size={16} />, available: true },
|
||||
{ platform: 'macos', label: 'macOS', icon: <Apple size={16} />, available: true },
|
||||
{ platform: 'linux', label: 'Linux', icon: <Server size={16} />, available: true },
|
||||
{ platform: 'android', label: 'Android', icon: <Smartphone size={16} />, available: true },
|
||||
{ platform: 'ios', label: 'iOS', icon: <Smartphone size={16} />, available: true },
|
||||
{ platform: 'web', label: 'Web', icon: <Globe size={16} />, available: true },
|
||||
{ platform: 'wechat-minigame', label: 'WeChat Mini Game', icon: <Gamepad2 size={16} />, available: true },
|
||||
];
|
||||
|
||||
const DEFAULT_SETTINGS: BuildSettings = {
|
||||
scenes: [],
|
||||
scriptingDefines: [],
|
||||
companyName: 'DefaultCompany',
|
||||
productName: 'MyGame',
|
||||
version: '0.1.0',
|
||||
developmentBuild: false,
|
||||
sourceMap: false,
|
||||
compressionMethod: 'Default',
|
||||
bundleModules: false,
|
||||
};
|
||||
|
||||
// ==================== i18n | 国际化 ====================
|
||||
|
||||
const i18n = {
|
||||
en: {
|
||||
buildProfiles: 'Build Profiles',
|
||||
addBuildProfile: 'Add Build Profile',
|
||||
playerSettings: 'Player Settings',
|
||||
assetImportOverrides: 'Asset Import Overrides',
|
||||
platforms: 'Platforms',
|
||||
sceneList: 'Scene List',
|
||||
active: 'Active',
|
||||
switchProfile: 'Switch Profile',
|
||||
build: 'Build',
|
||||
buildAndRun: 'Build And Run',
|
||||
buildData: 'Build Data',
|
||||
scriptingDefines: 'Scripting Defines',
|
||||
listIsEmpty: 'List is empty',
|
||||
addOpenScenes: 'Add Open Scenes',
|
||||
platformSettings: 'Platform Settings',
|
||||
architecture: 'Architecture',
|
||||
developmentBuild: 'Development Build',
|
||||
sourceMap: 'Source Map',
|
||||
compressionMethod: 'Compression Method',
|
||||
bundleModules: 'Bundle Modules',
|
||||
bundleModulesHint: 'Merge all modules into single file',
|
||||
separateModulesHint: 'Keep modules as separate files',
|
||||
playerSettingsOverrides: 'Player Settings Overrides',
|
||||
companyName: 'Company Name',
|
||||
productName: 'Product Name',
|
||||
version: 'Version',
|
||||
defaultIcon: 'Default Icon',
|
||||
none: 'None',
|
||||
// Build progress | 构建进度
|
||||
buildInProgress: 'Build in Progress',
|
||||
preparing: 'Preparing...',
|
||||
compiling: 'Compiling...',
|
||||
packaging: 'Packaging assets...',
|
||||
copying: 'Copying files...',
|
||||
postProcessing: 'Post-processing...',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
buildSucceeded: 'Build succeeded!',
|
||||
buildFailed: 'Build failed',
|
||||
warnings: 'Warnings',
|
||||
outputPath: 'Output Path',
|
||||
duration: 'Duration',
|
||||
},
|
||||
zh: {
|
||||
buildProfiles: '构建配置',
|
||||
addBuildProfile: '添加构建配置',
|
||||
playerSettings: '玩家设置',
|
||||
assetImportOverrides: '资源导入覆盖',
|
||||
platforms: '平台',
|
||||
sceneList: '场景列表',
|
||||
active: '激活',
|
||||
switchProfile: '切换配置',
|
||||
build: '构建',
|
||||
buildAndRun: '构建并运行',
|
||||
buildData: '构建数据',
|
||||
scriptingDefines: '脚本定义',
|
||||
listIsEmpty: '列表为空',
|
||||
addOpenScenes: '添加已打开的场景',
|
||||
platformSettings: '平台设置',
|
||||
architecture: '架构',
|
||||
developmentBuild: '开发版本',
|
||||
sourceMap: 'Source Map',
|
||||
compressionMethod: '压缩方式',
|
||||
bundleModules: '打包模块',
|
||||
bundleModulesHint: '合并所有模块为单文件',
|
||||
separateModulesHint: '保持模块为独立文件',
|
||||
playerSettingsOverrides: '玩家设置覆盖',
|
||||
companyName: '公司名称',
|
||||
productName: '产品名称',
|
||||
version: '版本',
|
||||
defaultIcon: '默认图标',
|
||||
none: '无',
|
||||
// Build progress | 构建进度
|
||||
buildInProgress: '正在构建',
|
||||
preparing: '准备中...',
|
||||
compiling: '编译中...',
|
||||
packaging: '打包资源...',
|
||||
copying: '复制文件...',
|
||||
postProcessing: '后处理...',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
buildSucceeded: '构建成功!',
|
||||
buildFailed: '构建失败',
|
||||
warnings: '警告',
|
||||
outputPath: '输出路径',
|
||||
duration: '耗时',
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== Props | 属性 ====================
|
||||
|
||||
interface BuildSettingsPanelProps {
|
||||
projectPath?: string;
|
||||
locale?: string;
|
||||
buildService?: BuildService;
|
||||
sceneManager?: SceneManagerService;
|
||||
onBuild?: (profile: BuildProfile, settings: BuildSettings) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// ==================== Component | 组件 ====================
|
||||
|
||||
export function BuildSettingsPanel({
|
||||
projectPath,
|
||||
locale = 'en',
|
||||
buildService,
|
||||
sceneManager,
|
||||
onBuild,
|
||||
onClose
|
||||
}: BuildSettingsPanelProps) {
|
||||
const t = i18n[locale as keyof typeof i18n] || i18n.en;
|
||||
|
||||
// State | 状态
|
||||
const [profiles, setProfiles] = useState<BuildProfile[]>([
|
||||
{ id: 'web-dev', name: 'Web - Development', platform: 'web', isActive: true },
|
||||
{ id: 'web-prod', name: 'Web - Production', platform: 'web' },
|
||||
{ id: 'wechat', name: 'WeChat Mini Game', platform: 'wechat-minigame' },
|
||||
]);
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<PlatformType>('web');
|
||||
const [selectedProfile, setSelectedProfile] = useState<BuildProfile | null>(profiles[0] || null);
|
||||
const [settings, setSettings] = useState<BuildSettings>(DEFAULT_SETTINGS);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
sceneList: true,
|
||||
scriptingDefines: true,
|
||||
platformSettings: true,
|
||||
playerSettings: true,
|
||||
});
|
||||
|
||||
// Build state | 构建状态
|
||||
const [isBuilding, setIsBuilding] = useState(false);
|
||||
const [buildProgress, setBuildProgress] = useState<BuildProgress | null>(null);
|
||||
const [buildResult, setBuildResult] = useState<{
|
||||
success: boolean;
|
||||
outputPath: string;
|
||||
duration: number;
|
||||
warnings: string[];
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
const [showBuildProgress, setShowBuildProgress] = useState(false);
|
||||
const buildAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Handlers | 处理函数
|
||||
const toggleSection = useCallback((section: string) => {
|
||||
setExpandedSections(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handlePlatformSelect = useCallback((platform: PlatformType) => {
|
||||
setSelectedPlatform(platform);
|
||||
// Find first profile for this platform | 查找此平台的第一个配置
|
||||
const profile = profiles.find(p => p.platform === platform);
|
||||
setSelectedProfile(profile || null);
|
||||
}, [profiles]);
|
||||
|
||||
const handleProfileSelect = useCallback((profile: BuildProfile) => {
|
||||
setSelectedProfile(profile);
|
||||
setSelectedPlatform(profile.platform);
|
||||
}, []);
|
||||
|
||||
const handleAddProfile = useCallback(() => {
|
||||
const newProfile: BuildProfile = {
|
||||
id: `profile-${Date.now()}`,
|
||||
name: `${selectedPlatform} - New Profile`,
|
||||
platform: selectedPlatform,
|
||||
};
|
||||
setProfiles(prev => [...prev, newProfile]);
|
||||
setSelectedProfile(newProfile);
|
||||
}, [selectedPlatform]);
|
||||
|
||||
// Map platform type to BuildPlatform enum | 将平台类型映射到 BuildPlatform 枚举
|
||||
const getPlatformEnum = useCallback((platformType: PlatformType): BuildPlatform => {
|
||||
const platformMap: Record<PlatformType, BuildPlatform> = {
|
||||
'web': BuildPlatform.Web,
|
||||
'wechat-minigame': BuildPlatform.WeChatMiniGame,
|
||||
'windows': BuildPlatform.Desktop,
|
||||
'macos': BuildPlatform.Desktop,
|
||||
'linux': BuildPlatform.Desktop,
|
||||
'android': BuildPlatform.Android,
|
||||
'ios': BuildPlatform.iOS
|
||||
};
|
||||
return platformMap[platformType];
|
||||
}, []);
|
||||
|
||||
const handleBuild = useCallback(async () => {
|
||||
if (!selectedProfile || !projectPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Call external handler if provided | 如果提供了外部处理程序则调用
|
||||
if (onBuild) {
|
||||
onBuild(selectedProfile, settings);
|
||||
}
|
||||
|
||||
// Use BuildService if available | 如果可用则使用 BuildService
|
||||
if (buildService) {
|
||||
setIsBuilding(true);
|
||||
setBuildProgress(null);
|
||||
setBuildResult(null);
|
||||
setShowBuildProgress(true);
|
||||
|
||||
try {
|
||||
const platform = getPlatformEnum(selectedProfile.platform);
|
||||
const baseConfig = {
|
||||
platform,
|
||||
outputPath: `${projectPath}/build/${selectedProfile.platform}`,
|
||||
isRelease: !settings.developmentBuild,
|
||||
sourceMap: settings.sourceMap,
|
||||
scenes: settings.scenes.filter(s => s.enabled).map(s => s.path)
|
||||
};
|
||||
|
||||
// Build platform-specific config | 构建平台特定配置
|
||||
let buildConfig: BuildConfig;
|
||||
if (platform === BuildPlatform.Web) {
|
||||
const webConfig: WebBuildConfig = {
|
||||
...baseConfig,
|
||||
platform: BuildPlatform.Web,
|
||||
format: 'iife',
|
||||
bundleModules: settings.bundleModules,
|
||||
generateHtml: true
|
||||
};
|
||||
buildConfig = webConfig;
|
||||
} else if (platform === BuildPlatform.WeChatMiniGame) {
|
||||
const wechatConfig: WeChatBuildConfig = {
|
||||
...baseConfig,
|
||||
platform: BuildPlatform.WeChatMiniGame,
|
||||
appId: '',
|
||||
useSubpackages: false,
|
||||
mainPackageLimit: 4096,
|
||||
usePlugins: false
|
||||
};
|
||||
buildConfig = wechatConfig;
|
||||
} else {
|
||||
buildConfig = baseConfig;
|
||||
}
|
||||
|
||||
// Execute build with progress callback | 执行构建并传入进度回调
|
||||
const result = await buildService.build(buildConfig, (progress) => {
|
||||
setBuildProgress(progress);
|
||||
});
|
||||
|
||||
// Set result | 设置结果
|
||||
setBuildResult({
|
||||
success: result.success,
|
||||
outputPath: result.outputPath,
|
||||
duration: result.duration,
|
||||
warnings: result.warnings,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Build failed:', error);
|
||||
setBuildResult({
|
||||
success: false,
|
||||
outputPath: '',
|
||||
duration: 0,
|
||||
warnings: [],
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
} finally {
|
||||
setIsBuilding(false);
|
||||
}
|
||||
}
|
||||
}, [selectedProfile, settings, projectPath, buildService, onBuild, getPlatformEnum]);
|
||||
|
||||
// Monitor build progress from service | 从服务监控构建进度
|
||||
useEffect(() => {
|
||||
if (!buildService || !isBuilding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const task = buildService.getCurrentTask();
|
||||
if (task) {
|
||||
setBuildProgress(task.progress);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [buildService, isBuilding]);
|
||||
|
||||
const handleCancelBuild = useCallback(() => {
|
||||
if (buildService) {
|
||||
buildService.cancelBuild();
|
||||
}
|
||||
}, [buildService]);
|
||||
|
||||
const handleCloseBuildProgress = useCallback(() => {
|
||||
if (!isBuilding) {
|
||||
setShowBuildProgress(false);
|
||||
setBuildProgress(null);
|
||||
setBuildResult(null);
|
||||
}
|
||||
}, [isBuilding]);
|
||||
|
||||
// Get status message | 获取状态消息
|
||||
const getStatusMessage = useCallback((status: BuildStatus): string => {
|
||||
const statusMessages: Record<BuildStatus, keyof typeof i18n.en> = {
|
||||
[BuildStatus.Idle]: 'preparing',
|
||||
[BuildStatus.Preparing]: 'preparing',
|
||||
[BuildStatus.Compiling]: 'compiling',
|
||||
[BuildStatus.Packaging]: 'packaging',
|
||||
[BuildStatus.Copying]: 'copying',
|
||||
[BuildStatus.PostProcessing]: 'postProcessing',
|
||||
[BuildStatus.Completed]: 'completed',
|
||||
[BuildStatus.Failed]: 'failed',
|
||||
[BuildStatus.Cancelled]: 'cancelled'
|
||||
};
|
||||
return t[statusMessages[status]] || status;
|
||||
}, [t]);
|
||||
|
||||
const handleAddScene = useCallback(() => {
|
||||
if (!sceneManager) {
|
||||
console.warn('SceneManagerService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const sceneState = sceneManager.getSceneState();
|
||||
const currentScenePath = sceneState.currentScenePath;
|
||||
|
||||
if (!currentScenePath) {
|
||||
console.warn('No scene is currently open');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if scene is already in the list | 检查场景是否已在列表中
|
||||
const exists = settings.scenes.some(s => s.path === currentScenePath);
|
||||
if (exists) {
|
||||
console.log('Scene already in list:', currentScenePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add current scene to the list | 将当前场景添加到列表中
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
scenes: [...prev.scenes, { path: currentScenePath, enabled: true }]
|
||||
}));
|
||||
}, [sceneManager, settings.scenes]);
|
||||
|
||||
const handleAddDefine = useCallback(() => {
|
||||
const define = prompt('Enter scripting define:');
|
||||
if (define) {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
scriptingDefines: [...prev.scriptingDefines, define]
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveDefine = useCallback((index: number) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
scriptingDefines: prev.scriptingDefines.filter((_, i) => i !== index)
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Get platform config | 获取平台配置
|
||||
const currentPlatformConfig = PLATFORMS.find(p => p.platform === selectedPlatform);
|
||||
|
||||
return (
|
||||
<div className="build-settings-panel">
|
||||
{/* Header Tabs | 头部标签 */}
|
||||
<div className="build-settings-header">
|
||||
<div className="build-settings-tabs">
|
||||
<div className="build-settings-tab active">
|
||||
<Package size={14} />
|
||||
{t.buildProfiles}
|
||||
</div>
|
||||
</div>
|
||||
<div className="build-settings-header-actions">
|
||||
<button className="build-settings-header-btn">{t.playerSettings}</button>
|
||||
<button className="build-settings-header-btn">{t.assetImportOverrides}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Profile Bar | 添加配置栏 */}
|
||||
<div className="build-settings-add-bar">
|
||||
<button className="build-settings-add-btn" onClick={handleAddProfile}>
|
||||
<Plus size={14} />
|
||||
{t.addBuildProfile}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Content | 主要内容 */}
|
||||
<div className="build-settings-content">
|
||||
{/* Left Sidebar | 左侧边栏 */}
|
||||
<div className="build-settings-sidebar">
|
||||
{/* Platforms Section | 平台部分 */}
|
||||
<div className="build-settings-section">
|
||||
<div className="build-settings-section-header">{t.platforms}</div>
|
||||
<div className="build-settings-platform-list">
|
||||
{PLATFORMS.map(platform => {
|
||||
const isActive = profiles.some(p => p.platform === platform.platform && p.isActive);
|
||||
return (
|
||||
<div
|
||||
key={platform.platform}
|
||||
className={`build-settings-platform-item ${selectedPlatform === platform.platform ? 'selected' : ''}`}
|
||||
onClick={() => handlePlatformSelect(platform.platform)}
|
||||
>
|
||||
<span className="build-settings-platform-icon">{platform.icon}</span>
|
||||
<span className="build-settings-platform-label">{platform.label}</span>
|
||||
{isActive && <span className="build-settings-active-badge">{t.active}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Profiles Section | 构建配置部分 */}
|
||||
<div className="build-settings-section">
|
||||
<div className="build-settings-section-header">{t.buildProfiles}</div>
|
||||
<div className="build-settings-profile-list">
|
||||
{profiles
|
||||
.filter(p => p.platform === selectedPlatform)
|
||||
.map(profile => (
|
||||
<div
|
||||
key={profile.id}
|
||||
className={`build-settings-profile-item ${selectedProfile?.id === profile.id ? 'selected' : ''}`}
|
||||
onClick={() => handleProfileSelect(profile)}
|
||||
>
|
||||
<span className="build-settings-profile-icon">
|
||||
{currentPlatformConfig?.icon}
|
||||
</span>
|
||||
<span className="build-settings-profile-name">{profile.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel | 右侧面板 */}
|
||||
<div className="build-settings-details">
|
||||
{selectedProfile ? (
|
||||
<>
|
||||
{/* Profile Header | 配置头部 */}
|
||||
<div className="build-settings-details-header">
|
||||
<div className="build-settings-details-title">
|
||||
<span className="build-settings-details-icon">
|
||||
{currentPlatformConfig?.icon}
|
||||
</span>
|
||||
<div className="build-settings-details-info">
|
||||
<h3>{selectedProfile.name}</h3>
|
||||
<span>{currentPlatformConfig?.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="build-settings-details-actions">
|
||||
<button className="build-settings-btn secondary">{t.switchProfile}</button>
|
||||
<button className="build-settings-btn primary" onClick={handleBuild}>
|
||||
{t.build}
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Data Section | 构建数据部分 */}
|
||||
<div className="build-settings-card">
|
||||
<div className="build-settings-card-header">{t.buildData}</div>
|
||||
|
||||
{/* Scene List | 场景列表 */}
|
||||
<div className="build-settings-field-group">
|
||||
<div
|
||||
className="build-settings-field-header"
|
||||
onClick={() => toggleSection('sceneList')}
|
||||
>
|
||||
{expandedSections.sceneList ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{t.sceneList}</span>
|
||||
</div>
|
||||
{expandedSections.sceneList && (
|
||||
<div className="build-settings-field-content">
|
||||
<div className="build-settings-scene-list">
|
||||
{settings.scenes.length === 0 ? (
|
||||
<div className="build-settings-empty-list"></div>
|
||||
) : (
|
||||
settings.scenes.map((scene, index) => (
|
||||
<div key={index} className="build-settings-scene-item">
|
||||
<input type="checkbox" checked={scene.enabled} readOnly />
|
||||
<span>{scene.path}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="build-settings-field-actions">
|
||||
<button className="build-settings-btn text" onClick={handleAddScene}>
|
||||
{t.addOpenScenes}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scripting Defines | 脚本定义 */}
|
||||
<div className="build-settings-field-group">
|
||||
<div
|
||||
className="build-settings-field-header"
|
||||
onClick={() => toggleSection('scriptingDefines')}
|
||||
>
|
||||
{expandedSections.scriptingDefines ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{t.scriptingDefines}</span>
|
||||
</div>
|
||||
{expandedSections.scriptingDefines && (
|
||||
<div className="build-settings-field-content">
|
||||
<div className="build-settings-defines-list">
|
||||
{settings.scriptingDefines.length === 0 ? (
|
||||
<div className="build-settings-empty-text">{t.listIsEmpty}</div>
|
||||
) : (
|
||||
settings.scriptingDefines.map((define, index) => (
|
||||
<div key={index} className="build-settings-define-item">
|
||||
<span>{define}</span>
|
||||
<button onClick={() => handleRemoveDefine(index)}>
|
||||
<Minus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="build-settings-list-actions">
|
||||
<button onClick={handleAddDefine}><Plus size={14} /></button>
|
||||
<button disabled={settings.scriptingDefines.length === 0}>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform Settings Section | 平台设置部分 */}
|
||||
<div className="build-settings-card">
|
||||
<div className="build-settings-card-header">{t.platformSettings}</div>
|
||||
|
||||
<div className="build-settings-field-group">
|
||||
<div
|
||||
className="build-settings-field-header"
|
||||
onClick={() => toggleSection('platformSettings')}
|
||||
>
|
||||
{expandedSections.platformSettings ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{currentPlatformConfig?.label} Settings</span>
|
||||
</div>
|
||||
{expandedSections.platformSettings && (
|
||||
<div className="build-settings-field-content">
|
||||
<div className="build-settings-form">
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.developmentBuild}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.developmentBuild}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
developmentBuild: e.target.checked
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.sourceMap}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.sourceMap}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
sourceMap: e.target.checked
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.compressionMethod}</label>
|
||||
<select
|
||||
value={settings.compressionMethod}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
compressionMethod: e.target.value as any
|
||||
}))}
|
||||
>
|
||||
<option value="Default">Default</option>
|
||||
<option value="LZ4">LZ4</option>
|
||||
<option value="LZ4HC">LZ4HC</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.bundleModules}</label>
|
||||
<div className="build-settings-toggle-group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.bundleModules}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
bundleModules: e.target.checked
|
||||
}))}
|
||||
/>
|
||||
<span className="build-settings-hint">
|
||||
{settings.bundleModules ? t.bundleModulesHint : t.separateModulesHint}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Player Settings Overrides | 玩家设置覆盖 */}
|
||||
<div className="build-settings-card">
|
||||
<div className="build-settings-card-header">
|
||||
{t.playerSettingsOverrides}
|
||||
<button className="build-settings-more-btn">
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="build-settings-field-group">
|
||||
<div
|
||||
className="build-settings-field-header"
|
||||
onClick={() => toggleSection('playerSettings')}
|
||||
>
|
||||
{expandedSections.playerSettings ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>Player Settings</span>
|
||||
</div>
|
||||
{expandedSections.playerSettings && (
|
||||
<div className="build-settings-field-content">
|
||||
<div className="build-settings-form">
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.companyName}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.companyName}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
companyName: e.target.value
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.productName}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.productName}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
productName: e.target.value
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.version}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.version}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
version: e.target.value
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.defaultIcon}</label>
|
||||
<div className="build-settings-icon-picker">
|
||||
<span>{t.none}</span>
|
||||
<span className="build-settings-icon-hint">(Texture 2D)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="build-settings-no-selection">
|
||||
<p>Select a platform or build profile</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Progress Dialog | 构建进度对话框 */}
|
||||
{showBuildProgress && (
|
||||
<div className="build-progress-overlay">
|
||||
<div className="build-progress-dialog">
|
||||
<div className="build-progress-header">
|
||||
<h3>{t.buildInProgress}</h3>
|
||||
{!isBuilding && (
|
||||
<button
|
||||
className="build-progress-close"
|
||||
onClick={handleCloseBuildProgress}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="build-progress-content">
|
||||
{/* Status Icon | 状态图标 */}
|
||||
<div className="build-progress-status-icon">
|
||||
{isBuilding ? (
|
||||
<Loader2 size={48} className="build-progress-spinner" />
|
||||
) : buildResult?.success ? (
|
||||
<CheckCircle size={48} className="build-progress-success" />
|
||||
) : (
|
||||
<XCircle size={48} className="build-progress-error" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Message | 状态消息 */}
|
||||
<div className="build-progress-message">
|
||||
{isBuilding ? (
|
||||
buildProgress?.message || getStatusMessage(buildProgress?.status || BuildStatus.Preparing)
|
||||
) : buildResult?.success ? (
|
||||
t.buildSucceeded
|
||||
) : (
|
||||
t.buildFailed
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar | 进度条 */}
|
||||
{isBuilding && buildProgress && (
|
||||
<div className="build-progress-bar-container">
|
||||
<div
|
||||
className="build-progress-bar"
|
||||
style={{ width: `${buildProgress.progress}%` }}
|
||||
/>
|
||||
<span className="build-progress-percent">
|
||||
{Math.round(buildProgress.progress)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Build Result Details | 构建结果详情 */}
|
||||
{!isBuilding && buildResult && (
|
||||
<div className="build-result-details">
|
||||
{buildResult.success && (
|
||||
<>
|
||||
<div className="build-result-row">
|
||||
<span className="build-result-label">{t.outputPath}:</span>
|
||||
<span className="build-result-value">{buildResult.outputPath}</span>
|
||||
</div>
|
||||
<div className="build-result-row">
|
||||
<span className="build-result-label">{t.duration}:</span>
|
||||
<span className="build-result-value">
|
||||
{(buildResult.duration / 1000).toFixed(2)}s
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error Message | 错误消息 */}
|
||||
{buildResult.error && (
|
||||
<div className="build-result-error">
|
||||
<AlertTriangle size={16} />
|
||||
<span>{buildResult.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings | 警告 */}
|
||||
{buildResult.warnings.length > 0 && (
|
||||
<div className="build-result-warnings">
|
||||
<div className="build-result-warnings-header">
|
||||
<AlertTriangle size={14} />
|
||||
<span>{t.warnings} ({buildResult.warnings.length})</span>
|
||||
</div>
|
||||
<ul className="build-result-warnings-list">
|
||||
{buildResult.warnings.map((warning, index) => (
|
||||
<li key={index}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions | 操作按钮 */}
|
||||
<div className="build-progress-actions">
|
||||
{isBuilding ? (
|
||||
<button
|
||||
className="build-settings-btn secondary"
|
||||
onClick={handleCancelBuild}
|
||||
>
|
||||
{t.cancel}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="build-settings-btn primary"
|
||||
onClick={handleCloseBuildProgress}
|
||||
>
|
||||
{t.close}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BuildSettingsPanel;
|
||||
62
packages/editor-app/src/components/BuildSettingsWindow.tsx
Normal file
62
packages/editor-app/src/components/BuildSettingsWindow.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Build Settings Window.
|
||||
* 构建设置窗口。
|
||||
*
|
||||
* A modal window that displays the build settings panel.
|
||||
* 显示构建设置面板的模态窗口。
|
||||
*/
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import type { BuildService, SceneManagerService } from '@esengine/editor-core';
|
||||
import { BuildSettingsPanel } from './BuildSettingsPanel';
|
||||
import '../styles/BuildSettingsWindow.css';
|
||||
|
||||
interface BuildSettingsWindowProps {
|
||||
projectPath?: string;
|
||||
locale?: string;
|
||||
buildService?: BuildService;
|
||||
sceneManager?: SceneManagerService;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function BuildSettingsWindow({
|
||||
projectPath,
|
||||
locale = 'en',
|
||||
buildService,
|
||||
sceneManager,
|
||||
onClose
|
||||
}: BuildSettingsWindowProps) {
|
||||
const t = locale === 'zh' ? {
|
||||
title: '构建设置'
|
||||
} : {
|
||||
title: 'Build Settings'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="build-settings-window-overlay">
|
||||
<div className="build-settings-window">
|
||||
<div className="build-settings-window-header">
|
||||
<h2>{t.title}</h2>
|
||||
<button
|
||||
className="build-settings-window-close"
|
||||
onClick={onClose}
|
||||
title="Close"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="build-settings-window-content">
|
||||
<BuildSettingsPanel
|
||||
projectPath={projectPath}
|
||||
locale={locale}
|
||||
buildService={buildService}
|
||||
sceneManager={sceneManager}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BuildSettingsWindow;
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import {
|
||||
Plus,
|
||||
Download,
|
||||
@@ -68,6 +69,21 @@ interface ContentBrowserProps {
|
||||
revealPath?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据图标名获取 Lucide 图标组件
|
||||
*/
|
||||
function getIconComponent(iconName: string | undefined, size: number = 16): React.ReactNode {
|
||||
if (!iconName) return <File size={size} />;
|
||||
|
||||
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
|
||||
const IconComponent = icons[iconName];
|
||||
if (IconComponent) {
|
||||
return <IconComponent size={size} />;
|
||||
}
|
||||
|
||||
return <File size={size} />;
|
||||
}
|
||||
|
||||
// 获取资产类型显示名称
|
||||
function getAssetTypeName(asset: AssetItem): string {
|
||||
if (asset.type === 'folder') return 'Folder';
|
||||
@@ -156,7 +172,8 @@ export function ContentBrowser({
|
||||
dockInLayout: 'Dock in Layout',
|
||||
noProject: 'No project loaded',
|
||||
empty: 'This folder is empty',
|
||||
newFolder: 'New Folder'
|
||||
newFolder: 'New Folder',
|
||||
newPrefix: 'New'
|
||||
},
|
||||
zh: {
|
||||
favorites: '收藏夹',
|
||||
@@ -169,7 +186,8 @@ export function ContentBrowser({
|
||||
dockInLayout: '停靠到布局',
|
||||
noProject: '未加载项目',
|
||||
empty: '文件夹为空',
|
||||
newFolder: '新建文件夹'
|
||||
newFolder: '新建文件夹',
|
||||
newPrefix: '新建'
|
||||
}
|
||||
}[locale] || {
|
||||
favorites: 'Favorites',
|
||||
@@ -182,7 +200,24 @@ export function ContentBrowser({
|
||||
dockInLayout: 'Dock in Layout',
|
||||
noProject: 'No project loaded',
|
||||
empty: 'This folder is empty',
|
||||
newFolder: 'New Folder'
|
||||
newFolder: 'New Folder',
|
||||
newPrefix: 'New'
|
||||
};
|
||||
|
||||
// 文件创建模板的 label 本地化映射
|
||||
const templateLabels: Record<string, { en: string; zh: string }> = {
|
||||
'Material': { en: 'Material', zh: '材质' },
|
||||
'Shader': { en: 'Shader', zh: '着色器' },
|
||||
'Tilemap': { en: 'Tilemap', zh: '瓦片地图' },
|
||||
'Tileset': { en: 'Tileset', zh: '瓦片集' },
|
||||
};
|
||||
|
||||
const getTemplateLabel = (label: string): string => {
|
||||
const mapping = templateLabels[label];
|
||||
if (mapping) {
|
||||
return locale === 'zh' ? mapping.zh : mapping.en;
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
// Build folder tree - use ref to avoid dependency cycle
|
||||
@@ -546,8 +581,10 @@ export function ContentBrowser({
|
||||
if (templates.length > 0) {
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
for (const template of templates) {
|
||||
const localizedLabel = getTemplateLabel(template.label);
|
||||
items.push({
|
||||
label: `New ${template.label}`,
|
||||
label: `${t.newPrefix} ${localizedLabel}`,
|
||||
icon: getIconComponent(template.icon, 16),
|
||||
onClick: () => {
|
||||
setContextMenu(null);
|
||||
if (currentPath) {
|
||||
|
||||
390
packages/editor-app/src/components/EditorViewport.tsx
Normal file
390
packages/editor-app/src/components/EditorViewport.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* EditorViewport Component
|
||||
* 编辑器视口组件
|
||||
*
|
||||
* A reusable viewport component for editor panels that need engine rendering.
|
||||
* Supports camera controls, overlays, and preview scenes.
|
||||
*
|
||||
* 用于需要引擎渲染的编辑器面板的可重用视口组件。
|
||||
* 支持相机控制、覆盖层和预览场景。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import type { ViewportCameraConfig, IViewportOverlay } from '@esengine/editor-core';
|
||||
import { ViewportService } from '../services/ViewportService';
|
||||
import '../styles/EditorViewport.css';
|
||||
|
||||
/**
|
||||
* EditorViewport configuration
|
||||
* 编辑器视口配置
|
||||
*/
|
||||
export interface EditorViewportConfig {
|
||||
/** Unique viewport identifier | 唯一视口标识符 */
|
||||
viewportId: string;
|
||||
/** Initial camera config | 初始相机配置 */
|
||||
initialCamera?: ViewportCameraConfig;
|
||||
/** Whether to show grid | 是否显示网格 */
|
||||
showGrid?: boolean;
|
||||
/** Whether to show gizmos | 是否显示辅助线 */
|
||||
showGizmos?: boolean;
|
||||
/** Background clear color | 背景清除颜色 */
|
||||
clearColor?: { r: number; g: number; b: number; a: number };
|
||||
/** Min zoom level | 最小缩放级别 */
|
||||
minZoom?: number;
|
||||
/** Max zoom level | 最大缩放级别 */
|
||||
maxZoom?: number;
|
||||
/** Enable camera pan | 启用相机平移 */
|
||||
enablePan?: boolean;
|
||||
/** Enable camera zoom | 启用相机缩放 */
|
||||
enableZoom?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* EditorViewport props
|
||||
* 编辑器视口属性
|
||||
*/
|
||||
export interface EditorViewportProps extends EditorViewportConfig {
|
||||
/** Class name for styling | 样式类名 */
|
||||
className?: string;
|
||||
/** Called when camera changes | 相机变化时的回调 */
|
||||
onCameraChange?: (camera: ViewportCameraConfig) => void;
|
||||
/** Called when viewport is ready | 视口准备就绪时的回调 */
|
||||
onReady?: () => void;
|
||||
/** Called on mouse down | 鼠标按下时的回调 */
|
||||
onMouseDown?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
|
||||
/** Called on mouse move | 鼠标移动时的回调 */
|
||||
onMouseMove?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
|
||||
/** Called on mouse up | 鼠标抬起时的回调 */
|
||||
onMouseUp?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
|
||||
/** Called on mouse wheel | 鼠标滚轮时的回调 */
|
||||
onWheel?: (e: React.WheelEvent, worldPos: { x: number; y: number }) => void;
|
||||
/** Render custom overlays | 渲染自定义覆盖层 */
|
||||
renderOverlays?: () => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* EditorViewport handle for imperative access
|
||||
* 编辑器视口句柄,用于命令式访问
|
||||
*/
|
||||
export interface EditorViewportHandle {
|
||||
/** Get current camera | 获取当前相机 */
|
||||
getCamera(): ViewportCameraConfig;
|
||||
/** Set camera | 设置相机 */
|
||||
setCamera(camera: ViewportCameraConfig): void;
|
||||
/** Reset camera to initial state | 重置相机到初始状态 */
|
||||
resetCamera(): void;
|
||||
/** Convert screen coordinates to world coordinates | 将屏幕坐标转换为世界坐标 */
|
||||
screenToWorld(screenX: number, screenY: number): { x: number; y: number };
|
||||
/** Convert world coordinates to screen coordinates | 将世界坐标转换为屏幕坐标 */
|
||||
worldToScreen(worldX: number, worldY: number): { x: number; y: number };
|
||||
/** Get canvas element | 获取画布元素 */
|
||||
getCanvas(): HTMLCanvasElement | null;
|
||||
/** Request render | 请求渲染 */
|
||||
requestRender(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* EditorViewport Component
|
||||
* 编辑器视口组件
|
||||
*/
|
||||
export const EditorViewport = forwardRef<EditorViewportHandle, EditorViewportProps>(function EditorViewport(
|
||||
{
|
||||
viewportId,
|
||||
initialCamera = { x: 0, y: 0, zoom: 1 },
|
||||
showGrid = true,
|
||||
showGizmos = false,
|
||||
clearColor,
|
||||
minZoom = 0.1,
|
||||
maxZoom = 10,
|
||||
enablePan = true,
|
||||
enableZoom = true,
|
||||
className,
|
||||
onCameraChange,
|
||||
onReady,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
onWheel,
|
||||
renderOverlays
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
// Camera state
|
||||
const [camera, setCamera] = useState<ViewportCameraConfig>(initialCamera);
|
||||
const cameraRef = useRef(camera);
|
||||
|
||||
// Drag state
|
||||
const isDraggingRef = useRef(false);
|
||||
const lastMousePosRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
// Keep camera ref in sync
|
||||
useEffect(() => {
|
||||
cameraRef.current = camera;
|
||||
}, [camera]);
|
||||
|
||||
// Screen to world conversion
|
||||
const screenToWorld = useCallback((screenX: number, screenY: number): { x: number; y: number } => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// Convert to canvas pixel coordinates
|
||||
const canvasX = (screenX - rect.left) * dpr;
|
||||
const canvasY = (screenY - rect.top) * dpr;
|
||||
|
||||
// Convert to centered coordinates (Y-up)
|
||||
const centeredX = canvasX - canvas.width / 2;
|
||||
const centeredY = canvas.height / 2 - canvasY;
|
||||
|
||||
// Apply inverse zoom and add camera position
|
||||
const cam = cameraRef.current;
|
||||
const worldX = centeredX / cam.zoom + cam.x;
|
||||
const worldY = centeredY / cam.zoom + cam.y;
|
||||
|
||||
return { x: worldX, y: worldY };
|
||||
}, []);
|
||||
|
||||
// World to screen conversion
|
||||
const worldToScreen = useCallback((worldX: number, worldY: number): { x: number; y: number } => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cam = cameraRef.current;
|
||||
|
||||
// Apply camera transform
|
||||
const centeredX = (worldX - cam.x) * cam.zoom;
|
||||
const centeredY = (worldY - cam.y) * cam.zoom;
|
||||
|
||||
// Convert from centered coordinates
|
||||
const canvasX = centeredX + canvas.width / 2;
|
||||
const canvasY = canvas.height / 2 - centeredY;
|
||||
|
||||
// Convert to screen coordinates
|
||||
const screenX = canvasX / dpr + rect.left;
|
||||
const screenY = canvasY / dpr + rect.top;
|
||||
|
||||
return { x: screenX, y: screenY };
|
||||
}, []);
|
||||
|
||||
// Request render
|
||||
const requestRender = useCallback(() => {
|
||||
const viewportService = ViewportService.getInstance();
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.renderToViewport(viewportId);
|
||||
}
|
||||
}, [viewportId]);
|
||||
|
||||
// Expose imperative handle
|
||||
useImperativeHandle(ref, () => ({
|
||||
getCamera: () => cameraRef.current,
|
||||
setCamera: (newCamera: ViewportCameraConfig) => {
|
||||
setCamera(newCamera);
|
||||
onCameraChange?.(newCamera);
|
||||
},
|
||||
resetCamera: () => {
|
||||
setCamera(initialCamera);
|
||||
onCameraChange?.(initialCamera);
|
||||
},
|
||||
screenToWorld,
|
||||
worldToScreen,
|
||||
getCanvas: () => canvasRef.current,
|
||||
requestRender
|
||||
}), [initialCamera, screenToWorld, worldToScreen, onCameraChange, requestRender]);
|
||||
|
||||
// Initialize viewport
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const canvasId = `editor-viewport-canvas-${viewportId}`;
|
||||
canvas.id = canvasId;
|
||||
|
||||
const viewportService = ViewportService.getInstance();
|
||||
|
||||
// Wait for service to be initialized
|
||||
const checkInit = () => {
|
||||
if (viewportService.isInitialized()) {
|
||||
// Register viewport
|
||||
viewportService.registerViewport(viewportId, canvasId);
|
||||
viewportService.setViewportConfig(viewportId, showGrid, showGizmos);
|
||||
viewportService.setViewportCamera(viewportId, camera);
|
||||
|
||||
setIsReady(true);
|
||||
onReady?.();
|
||||
} else {
|
||||
// Retry after a short delay
|
||||
setTimeout(checkInit, 100);
|
||||
}
|
||||
};
|
||||
|
||||
checkInit();
|
||||
|
||||
return () => {
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.unregisterViewport(viewportId);
|
||||
}
|
||||
};
|
||||
}, [viewportId]);
|
||||
|
||||
// Update viewport config when props change
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
|
||||
const viewportService = ViewportService.getInstance();
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.setViewportConfig(viewportId, showGrid, showGizmos);
|
||||
}
|
||||
}, [viewportId, showGrid, showGizmos, isReady]);
|
||||
|
||||
// Sync camera to viewport service
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
|
||||
const viewportService = ViewportService.getInstance();
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.setViewportCamera(viewportId, camera);
|
||||
}
|
||||
}, [viewportId, camera, isReady]);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const resizeCanvas = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
|
||||
if (isReady) {
|
||||
const viewportService = ViewportService.getInstance();
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.resizeViewport(viewportId, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
resizeCanvas();
|
||||
|
||||
let rafId: number | null = null;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
resizeCanvas();
|
||||
rafId = null;
|
||||
});
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [viewportId, isReady]);
|
||||
|
||||
// Mouse handlers
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
|
||||
// Middle or right button for camera pan
|
||||
if (enablePan && (e.button === 1 || e.button === 2)) {
|
||||
isDraggingRef.current = true;
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
onMouseDown?.(e, worldPos);
|
||||
}, [enablePan, screenToWorld, onMouseDown]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
|
||||
if (isDraggingRef.current && enablePan) {
|
||||
const deltaX = e.clientX - lastMousePosRef.current.x;
|
||||
const deltaY = e.clientY - lastMousePosRef.current.y;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
setCamera(prev => {
|
||||
const newCamera = {
|
||||
...prev,
|
||||
x: prev.x - (deltaX * dpr) / prev.zoom,
|
||||
y: prev.y + (deltaY * dpr) / prev.zoom
|
||||
};
|
||||
onCameraChange?.(newCamera);
|
||||
return newCamera;
|
||||
});
|
||||
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
onMouseMove?.(e, worldPos);
|
||||
}, [enablePan, screenToWorld, onMouseMove, onCameraChange]);
|
||||
|
||||
const handleMouseUp = useCallback((e: React.MouseEvent) => {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
isDraggingRef.current = false;
|
||||
onMouseUp?.(e, worldPos);
|
||||
}, [screenToWorld, onMouseUp]);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
|
||||
if (enableZoom) {
|
||||
e.preventDefault();
|
||||
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
|
||||
setCamera(prev => {
|
||||
const newZoom = Math.max(minZoom, Math.min(maxZoom, prev.zoom * zoomFactor));
|
||||
const newCamera = { ...prev, zoom: newZoom };
|
||||
onCameraChange?.(newCamera);
|
||||
return newCamera;
|
||||
});
|
||||
}
|
||||
|
||||
onWheel?.(e, worldPos);
|
||||
}, [enableZoom, minZoom, maxZoom, screenToWorld, onWheel, onCameraChange]);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`editor-viewport ${className || ''}`}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="editor-viewport-canvas"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
{renderOverlays?.()}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default EditorViewport;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,7 @@ interface MenuBarProps {
|
||||
onOpenAbout?: () => void;
|
||||
onCreatePlugin?: () => void;
|
||||
onReloadPlugins?: () => void;
|
||||
onOpenBuildSettings?: () => void;
|
||||
}
|
||||
|
||||
export function MenuBar({
|
||||
@@ -55,7 +56,8 @@ export function MenuBar({
|
||||
onToggleDevtools,
|
||||
onOpenAbout,
|
||||
onCreatePlugin,
|
||||
onReloadPlugins
|
||||
onReloadPlugins,
|
||||
onOpenBuildSettings
|
||||
}: MenuBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
||||
@@ -129,7 +131,8 @@ export function MenuBar({
|
||||
documentation: 'Documentation',
|
||||
checkForUpdates: 'Check for Updates',
|
||||
about: 'About',
|
||||
devtools: 'Developer Tools'
|
||||
devtools: 'Developer Tools',
|
||||
buildSettings: 'Build Settings'
|
||||
},
|
||||
zh: {
|
||||
file: '文件',
|
||||
@@ -164,7 +167,8 @@ export function MenuBar({
|
||||
documentation: '文档',
|
||||
checkForUpdates: '检查更新',
|
||||
about: '关于',
|
||||
devtools: '开发者工具'
|
||||
devtools: '开发者工具',
|
||||
buildSettings: '构建设置'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || key;
|
||||
@@ -178,6 +182,8 @@ export function MenuBar({
|
||||
{ label: t('saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene },
|
||||
{ label: t('saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs },
|
||||
{ separator: true },
|
||||
{ label: t('buildSettings'), shortcut: 'Ctrl+Shift+B', onClick: onOpenBuildSettings },
|
||||
{ separator: true },
|
||||
{ label: t('openProject'), onClick: onOpenProject },
|
||||
{ label: t('closeProject'), onClick: onCloseProject },
|
||||
{ separator: true },
|
||||
|
||||
305
packages/editor-app/src/components/ModuleListSetting.tsx
Normal file
305
packages/editor-app/src/components/ModuleListSetting.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Module List Setting Component.
|
||||
* 模块列表设置组件。
|
||||
*
|
||||
* Renders a list of engine modules with checkboxes to enable/disable.
|
||||
* 渲染引擎模块列表,带复选框以启用/禁用。
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { ChevronDown, ChevronRight, Package, AlertCircle } from 'lucide-react';
|
||||
import type { ModuleManifest, ModuleCategory } from '@esengine/editor-core';
|
||||
import './styles/ModuleListSetting.css';
|
||||
|
||||
/**
|
||||
* Module entry with enabled state.
|
||||
* 带启用状态的模块条目。
|
||||
*/
|
||||
interface ModuleEntry extends ModuleManifest {
|
||||
enabled: boolean;
|
||||
canDisable: boolean;
|
||||
disableReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for ModuleListSetting.
|
||||
*/
|
||||
interface ModuleListSettingProps {
|
||||
/** Module manifests (static) | 模块清单列表(静态) */
|
||||
modules?: ModuleManifest[];
|
||||
/** Function to get modules dynamically (sizes from module.json) | 动态获取模块的函数(大小来自 module.json) */
|
||||
getModules?: () => ModuleManifest[];
|
||||
/**
|
||||
* Module IDs list. Meaning depends on useBlacklist.
|
||||
* 模块 ID 列表。含义取决于 useBlacklist。
|
||||
* - useBlacklist=false: enabled modules (whitelist)
|
||||
* - useBlacklist=true: disabled modules (blacklist)
|
||||
*/
|
||||
value: string[];
|
||||
/** Callback when modules change | 模块变更回调 */
|
||||
onModulesChange: (moduleIds: string[]) => void;
|
||||
/**
|
||||
* Use blacklist mode: value contains disabled modules instead of enabled.
|
||||
* 使用黑名单模式:value 包含禁用的模块而不是启用的。
|
||||
* Default: false (whitelist mode)
|
||||
*/
|
||||
useBlacklist?: boolean;
|
||||
/** Validate if module can be disabled | 验证模块是否可禁用 */
|
||||
validateDisable?: (moduleId: string) => Promise<{ canDisable: boolean; reason?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable string.
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module List Setting Component.
|
||||
* 模块列表设置组件。
|
||||
*/
|
||||
export const ModuleListSetting: React.FC<ModuleListSettingProps> = ({
|
||||
modules: staticModules,
|
||||
getModules,
|
||||
value,
|
||||
onModulesChange,
|
||||
useBlacklist = false,
|
||||
validateDisable
|
||||
}) => {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['Core', 'Rendering']));
|
||||
const [validationError, setValidationError] = useState<{ moduleId: string; message: string } | null>(null);
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
|
||||
// Get modules from function or static prop
|
||||
// 从函数或静态 prop 获取模块
|
||||
const modules = useMemo(() => {
|
||||
if (getModules) {
|
||||
return getModules();
|
||||
}
|
||||
return staticModules || [];
|
||||
}, [getModules, staticModules]);
|
||||
|
||||
// Build module entries with enabled state | 构建带启用状态的模块条目
|
||||
// In blacklist mode: enabled = NOT in value list
|
||||
// In whitelist mode: enabled = IN value list
|
||||
const moduleEntries: ModuleEntry[] = useMemo(() => {
|
||||
return modules.map(mod => {
|
||||
let enabled: boolean;
|
||||
if (mod.isCore) {
|
||||
enabled = true; // Core modules always enabled
|
||||
} else if (useBlacklist) {
|
||||
enabled = !value.includes(mod.id); // Blacklist: NOT in list = enabled
|
||||
} else {
|
||||
enabled = value.includes(mod.id); // Whitelist: IN list = enabled
|
||||
}
|
||||
return {
|
||||
...mod,
|
||||
enabled,
|
||||
canDisable: !mod.isCore,
|
||||
disableReason: mod.isCore ? 'Core module cannot be disabled' : undefined
|
||||
};
|
||||
});
|
||||
}, [modules, value, useBlacklist]);
|
||||
|
||||
// Group by category | 按分类分组
|
||||
const groupedModules = useMemo(() => {
|
||||
const groups = new Map<string, ModuleEntry[]>();
|
||||
const categoryOrder: ModuleCategory[] = ['Core', 'Rendering', 'Physics', 'AI', 'Audio', 'Networking', 'Other'];
|
||||
|
||||
// Initialize groups | 初始化分组
|
||||
for (const cat of categoryOrder) {
|
||||
groups.set(cat, []);
|
||||
}
|
||||
|
||||
// Group modules | 分组模块
|
||||
for (const mod of moduleEntries) {
|
||||
const cat = mod.category || 'Other';
|
||||
if (!groups.has(cat)) {
|
||||
groups.set(cat, []);
|
||||
}
|
||||
groups.get(cat)!.push(mod);
|
||||
}
|
||||
|
||||
// Filter empty groups | 过滤空分组
|
||||
const result = new Map<string, ModuleEntry[]>();
|
||||
for (const [cat, mods] of groups) {
|
||||
if (mods.length > 0) {
|
||||
result.set(cat, mods);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [moduleEntries]);
|
||||
|
||||
// Calculate total size (JS + WASM) | 计算总大小(JS + WASM)
|
||||
const { totalJsSize, totalWasmSize, totalSize } = useMemo(() => {
|
||||
let js = 0;
|
||||
let wasm = 0;
|
||||
for (const m of moduleEntries) {
|
||||
if (m.enabled) {
|
||||
js += m.jsSize || 0;
|
||||
wasm += m.wasmSize || 0;
|
||||
}
|
||||
}
|
||||
return { totalJsSize: js, totalWasmSize: wasm, totalSize: js + wasm };
|
||||
}, [moduleEntries]);
|
||||
|
||||
// Toggle category expansion | 切换分类展开
|
||||
const toggleCategory = useCallback((category: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) {
|
||||
next.delete(category);
|
||||
} else {
|
||||
next.add(category);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle module toggle | 处理模块切换
|
||||
const handleModuleToggle = useCallback(async (module: ModuleEntry, enabled: boolean) => {
|
||||
if (module.isCore) return;
|
||||
|
||||
// If disabling, validate first | 如果禁用,先验证
|
||||
if (!enabled && validateDisable) {
|
||||
setLoading(module.id);
|
||||
try {
|
||||
const result = await validateDisable(module.id);
|
||||
if (!result.canDisable) {
|
||||
setValidationError({
|
||||
moduleId: module.id,
|
||||
message: result.reason || `Cannot disable ${module.displayName}`
|
||||
});
|
||||
setLoading(null);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Update module list based on mode
|
||||
let newValue: string[];
|
||||
|
||||
if (useBlacklist) {
|
||||
// Blacklist mode: value contains disabled modules
|
||||
if (enabled) {
|
||||
// Remove from blacklist (and also remove dependencies)
|
||||
const toRemove = new Set([module.id]);
|
||||
// Also enable dependencies if they were disabled
|
||||
for (const depId of module.dependencies) {
|
||||
toRemove.add(depId);
|
||||
}
|
||||
newValue = value.filter(id => !toRemove.has(id));
|
||||
} else {
|
||||
// Add to blacklist
|
||||
newValue = [...value, module.id];
|
||||
}
|
||||
} else {
|
||||
// Whitelist mode: value contains enabled modules
|
||||
if (enabled) {
|
||||
// Add to whitelist (and dependencies)
|
||||
newValue = [...value];
|
||||
const toEnable = [module.id, ...module.dependencies];
|
||||
for (const id of toEnable) {
|
||||
if (!newValue.includes(id)) {
|
||||
newValue.push(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remove from whitelist
|
||||
newValue = value.filter(id => id !== module.id);
|
||||
}
|
||||
}
|
||||
|
||||
onModulesChange(newValue);
|
||||
}, [value, useBlacklist, onModulesChange, validateDisable]);
|
||||
|
||||
return (
|
||||
<div className="module-list-setting">
|
||||
{/* Module categories | 模块分类 */}
|
||||
<div className="module-list-categories">
|
||||
{Array.from(groupedModules.entries()).map(([category, mods]) => (
|
||||
<div key={category} className="module-category-group">
|
||||
<div
|
||||
className="module-category-header"
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
{expandedCategories.has(category) ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
<span className="module-category-name">{category}</span>
|
||||
<span className="module-category-count">
|
||||
{mods.filter(m => m.enabled).length}/{mods.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedCategories.has(category) && (
|
||||
<div className="module-category-items">
|
||||
{mods.map(mod => (
|
||||
<div
|
||||
key={mod.id}
|
||||
className={`module-item ${mod.enabled ? 'enabled' : ''} ${loading === mod.id ? 'loading' : ''}`}
|
||||
>
|
||||
<label className="module-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mod.enabled}
|
||||
disabled={mod.isCore || loading === mod.id}
|
||||
onChange={(e) => handleModuleToggle(mod, e.target.checked)}
|
||||
/>
|
||||
<Package size={14} className="module-icon" />
|
||||
<span className="module-name">{mod.displayName}</span>
|
||||
{mod.isCore && (
|
||||
<span className="module-badge core">Core</span>
|
||||
)}
|
||||
</label>
|
||||
{(mod.jsSize || mod.wasmSize) ? (
|
||||
<span className="module-size">
|
||||
{mod.isCore ? '' : '+'}
|
||||
{formatBytes((mod.jsSize || 0) + (mod.wasmSize || 0))}
|
||||
{(mod.wasmSize ?? 0) > 0 && (
|
||||
<span className="module-wasm-indicator" title="Includes WASM">⚡</span>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Size footer | 大小页脚 */}
|
||||
<div className="module-list-footer">
|
||||
<span className="module-list-size-label">Runtime size:</span>
|
||||
<span className="module-list-size-value">
|
||||
{formatBytes(totalSize)}
|
||||
{totalWasmSize > 0 && (
|
||||
<span className="module-size-breakdown">
|
||||
(JS: {formatBytes(totalJsSize)} + WASM: {formatBytes(totalWasmSize)})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Validation error toast | 验证错误提示 */}
|
||||
{validationError && (
|
||||
<div className="module-validation-error">
|
||||
<AlertCircle size={14} />
|
||||
<span>{validationError.message}</span>
|
||||
<button onClick={() => setValidationError(null)}>Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModuleListSetting;
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { PluginManager, type RegisteredPlugin, type PluginCategory, ProjectService } from '@esengine/editor-core';
|
||||
import { PluginManager, type RegisteredPlugin, type ModuleCategory, ProjectService } from '@esengine/editor-core';
|
||||
import { Check, Lock, Package } from 'lucide-react';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import '../styles/PluginListSetting.css';
|
||||
@@ -20,21 +20,17 @@ interface PluginListSettingProps {
|
||||
pluginManager: PluginManager;
|
||||
}
|
||||
|
||||
const categoryLabels: Record<PluginCategory, { zh: string; en: string }> = {
|
||||
core: { zh: '核心', en: 'Core' },
|
||||
rendering: { zh: '渲染', en: 'Rendering' },
|
||||
ui: { zh: 'UI', en: 'UI' },
|
||||
ai: { zh: 'AI', en: 'AI' },
|
||||
physics: { zh: '物理', en: 'Physics' },
|
||||
audio: { zh: '音频', en: 'Audio' },
|
||||
networking: { zh: '网络', en: 'Networking' },
|
||||
tools: { zh: '工具', en: 'Tools' },
|
||||
scripting: { zh: '脚本', en: 'Scripting' },
|
||||
content: { zh: '内容', en: 'Content' },
|
||||
tilemap: { zh: '瓦片地图', en: 'Tilemap' }
|
||||
const categoryLabels: Record<ModuleCategory, { zh: string; en: string }> = {
|
||||
Core: { zh: '核心', en: 'Core' },
|
||||
Rendering: { zh: '渲染', en: 'Rendering' },
|
||||
Physics: { zh: '物理', en: 'Physics' },
|
||||
AI: { zh: 'AI', en: 'AI' },
|
||||
Audio: { zh: '音频', en: 'Audio' },
|
||||
Networking: { zh: '网络', en: 'Networking' },
|
||||
Other: { zh: '其他', en: 'Other' }
|
||||
};
|
||||
|
||||
const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'scripting', 'physics', 'audio', 'networking', 'tilemap', 'tools', 'content'];
|
||||
const categoryOrder: ModuleCategory[] = ['Core', 'Rendering', 'Physics', 'AI', 'Audio', 'Networking', 'Other'];
|
||||
|
||||
export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
|
||||
@@ -56,13 +52,13 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
};
|
||||
|
||||
const handleToggle = async (pluginId: string) => {
|
||||
const plugin = plugins.find(p => p.plugin.descriptor.id === pluginId);
|
||||
const plugin = plugins.find(p => p.plugin.manifest.id === pluginId);
|
||||
if (!plugin) return;
|
||||
|
||||
const descriptor = plugin.plugin.descriptor;
|
||||
const manifest = plugin.plugin.manifest;
|
||||
|
||||
// 核心插件不可禁用
|
||||
if (descriptor.isCore) {
|
||||
if (manifest.isCore) {
|
||||
showWarning('核心插件不可禁用');
|
||||
return;
|
||||
}
|
||||
@@ -71,14 +67,14 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
|
||||
// 检查依赖(启用时)
|
||||
if (newEnabled) {
|
||||
const deps = descriptor.dependencies || [];
|
||||
const missingDeps = deps.filter(dep => {
|
||||
const depPlugin = plugins.find(p => p.plugin.descriptor.id === dep.id);
|
||||
const deps = manifest.dependencies || [];
|
||||
const missingDeps = deps.filter((depId: string) => {
|
||||
const depPlugin = plugins.find(p => p.plugin.manifest.id === depId);
|
||||
return depPlugin && !depPlugin.enabled;
|
||||
});
|
||||
|
||||
if (missingDeps.length > 0) {
|
||||
showWarning(`需要先启用依赖插件: ${missingDeps.map(d => d.id).join(', ')}`);
|
||||
showWarning(`需要先启用依赖插件: ${missingDeps.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -100,7 +96,7 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
|
||||
// 更新本地状态
|
||||
setPlugins(plugins.map(p => {
|
||||
if (p.plugin.descriptor.id === pluginId) {
|
||||
if (p.plugin.manifest.id === pluginId) {
|
||||
return { ...p, enabled: newEnabled };
|
||||
}
|
||||
return p;
|
||||
@@ -115,7 +111,7 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
|
||||
if (notificationService) {
|
||||
notificationService.show(
|
||||
newEnabled ? `已启用插件: ${descriptor.name}` : `已禁用插件: ${descriptor.name}`,
|
||||
newEnabled ? `已启用插件: ${manifest.displayName}` : `已禁用插件: ${manifest.displayName}`,
|
||||
'success',
|
||||
2000
|
||||
);
|
||||
@@ -135,8 +131,8 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
|
||||
// 获取当前启用的插件列表(排除核心插件)
|
||||
const enabledPlugins = pluginManager.getEnabledPlugins()
|
||||
.filter(p => !p.plugin.descriptor.isCore)
|
||||
.map(p => p.plugin.descriptor.id);
|
||||
.filter(p => !p.plugin.manifest.isCore)
|
||||
.map(p => p.plugin.manifest.id);
|
||||
|
||||
console.log('[PluginListSetting] Saving enabled plugins:', enabledPlugins);
|
||||
|
||||
@@ -150,13 +146,13 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
|
||||
// 按类别分组并排序
|
||||
const groupedPlugins = plugins.reduce((acc, plugin) => {
|
||||
const category = plugin.plugin.descriptor.category;
|
||||
const category = plugin.plugin.manifest.category;
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(plugin);
|
||||
return acc;
|
||||
}, {} as Record<PluginCategory, RegisteredPlugin[]>);
|
||||
}, {} as Record<ModuleCategory, RegisteredPlugin[]>);
|
||||
|
||||
// 按照 categoryOrder 排序
|
||||
const sortedCategories = categoryOrder.filter(cat => groupedPlugins[cat]?.length > 0);
|
||||
@@ -169,19 +165,19 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
{categoryLabels[category]?.zh || category}
|
||||
</div>
|
||||
<div className="plugin-list">
|
||||
{groupedPlugins[category].map(plugin => {
|
||||
const descriptor = plugin.plugin.descriptor;
|
||||
{groupedPlugins[category]?.map(plugin => {
|
||||
const manifest = plugin.plugin.manifest;
|
||||
const hasRuntime = !!plugin.plugin.runtimeModule;
|
||||
const hasEditor = !!plugin.plugin.editorModule;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={descriptor.id}
|
||||
className={`plugin-item ${plugin.enabled ? 'enabled' : ''} ${descriptor.isCore ? 'core' : ''}`}
|
||||
onClick={() => handleToggle(descriptor.id)}
|
||||
key={manifest.id}
|
||||
className={`plugin-item ${plugin.enabled ? 'enabled' : ''} ${manifest.isCore ? 'core' : ''}`}
|
||||
onClick={() => handleToggle(manifest.id)}
|
||||
>
|
||||
<div className="plugin-checkbox">
|
||||
{descriptor.isCore ? (
|
||||
{manifest.isCore ? (
|
||||
<Lock size={10} />
|
||||
) : (
|
||||
plugin.enabled && <Check size={10} />
|
||||
@@ -189,12 +185,12 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
</div>
|
||||
<div className="plugin-info">
|
||||
<div className="plugin-header">
|
||||
<span className="plugin-name">{descriptor.name}</span>
|
||||
<span className="plugin-version">v{descriptor.version}</span>
|
||||
<span className="plugin-name">{manifest.displayName}</span>
|
||||
<span className="plugin-version">v{manifest.version}</span>
|
||||
</div>
|
||||
{descriptor.description && (
|
||||
{manifest.description && (
|
||||
<div className="plugin-description">
|
||||
{descriptor.description}
|
||||
{manifest.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="plugin-modules">
|
||||
|
||||
@@ -928,11 +928,27 @@ function ContextMenuWithSubmenu({
|
||||
'other': { zh: '其他', en: 'Other' },
|
||||
};
|
||||
|
||||
// 实体创建模板的 label 本地化映射
|
||||
const entityTemplateLabels: Record<string, { zh: string; en: string }> = {
|
||||
'Sprite': { zh: '精灵', en: 'Sprite' },
|
||||
'Animated Sprite': { zh: '动画精灵', en: 'Animated Sprite' },
|
||||
'创建 Tilemap': { zh: '瓦片地图', en: 'Tilemap' },
|
||||
'Camera 2D': { zh: '2D 相机', en: 'Camera 2D' },
|
||||
};
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const labels = categoryLabels[category];
|
||||
return labels ? (locale === 'zh' ? labels.zh : labels.en) : category;
|
||||
};
|
||||
|
||||
const getEntityTemplateLabel = (label: string) => {
|
||||
const mapping = entityTemplateLabels[label];
|
||||
if (mapping) {
|
||||
return locale === 'zh' ? mapping.zh : mapping.en;
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
const templatesByCategory = pluginTemplates.reduce((acc, template) => {
|
||||
const cat = template.category || 'other';
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
@@ -996,7 +1012,7 @@ function ContextMenuWithSubmenu({
|
||||
{templates.map((template) => (
|
||||
<button key={template.id} onClick={() => onCreateFromTemplate(template)}>
|
||||
{getIconComponent(template.icon as string, 12)}
|
||||
<span>{template.label}</span>
|
||||
<span>{getEntityTemplateLabel(template.label)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { SettingsService } from '../services/SettingsService';
|
||||
import { SettingsRegistry, SettingCategory, SettingDescriptor, ProjectService, PluginManager, IPluginManager } from '@esengine/editor-core';
|
||||
import { SettingsRegistry, SettingCategory, SettingDescriptor, ProjectService, PluginManager, IPluginManager, ModuleManifest } from '@esengine/editor-core';
|
||||
import { PluginListSetting } from './PluginListSetting';
|
||||
import { ModuleListSetting } from './ModuleListSetting';
|
||||
import '../styles/SettingsWindow.css';
|
||||
|
||||
interface SettingsWindowProps {
|
||||
@@ -142,6 +143,9 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
} else if (key === 'project.uiDesignResolution.preset') {
|
||||
const resolution = projectService.getUIDesignResolution();
|
||||
initialValues.set(key, `${resolution.width}x${resolution.height}`);
|
||||
} else if (key === 'project.disabledModules') {
|
||||
// Load disabled modules from ProjectService
|
||||
initialValues.set(key, projectService.getDisabledModules());
|
||||
} else {
|
||||
initialValues.set(key, descriptor.defaultValue);
|
||||
}
|
||||
@@ -199,6 +203,8 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
let uiResolutionChanged = false;
|
||||
let newWidth = 1920;
|
||||
let newHeight = 1080;
|
||||
let disabledModulesChanged = false;
|
||||
let newDisabledModules: string[] = [];
|
||||
|
||||
for (const [key, value] of values.entries()) {
|
||||
if (key.startsWith('project.') && projectService) {
|
||||
@@ -215,6 +221,9 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
newHeight = h;
|
||||
uiResolutionChanged = true;
|
||||
}
|
||||
} else if (key === 'project.disabledModules') {
|
||||
newDisabledModules = value as string[];
|
||||
disabledModulesChanged = true;
|
||||
}
|
||||
changedSettings[key] = value;
|
||||
} else {
|
||||
@@ -227,6 +236,10 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
|
||||
}
|
||||
|
||||
if (disabledModulesChanged && projectService) {
|
||||
await projectService.setDisabledModules(newDisabledModules);
|
||||
}
|
||||
|
||||
console.log('[SettingsWindow] Saving settings, changedSettings:', changedSettings);
|
||||
window.dispatchEvent(new CustomEvent('settings:changed', {
|
||||
detail: changedSettings
|
||||
@@ -487,6 +500,31 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
);
|
||||
}
|
||||
|
||||
case 'moduleList': {
|
||||
// Get module data from setting's custom props
|
||||
// 从设置的自定义属性获取模块数据
|
||||
const moduleData = setting as SettingDescriptor & {
|
||||
modules?: ModuleManifest[];
|
||||
getModules?: () => ModuleManifest[];
|
||||
useBlacklist?: boolean;
|
||||
validateDisable?: (moduleId: string) => Promise<{ canDisable: boolean; reason?: string }>;
|
||||
};
|
||||
const moduleValue = value as string[] || [];
|
||||
|
||||
return (
|
||||
<div className="settings-module-list">
|
||||
<ModuleListSetting
|
||||
modules={moduleData.modules}
|
||||
getModules={moduleData.getModules}
|
||||
value={moduleValue}
|
||||
onModulesChange={(newValue) => handleValueChange(setting.key, newValue, setting)}
|
||||
useBlacklist={moduleData.useBlacklist}
|
||||
validateDisable={moduleData.validateDisable}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ interface TitleBarProps {
|
||||
onOpenAbout?: () => void;
|
||||
onCreatePlugin?: () => void;
|
||||
onReloadPlugins?: () => void;
|
||||
onOpenBuildSettings?: () => void;
|
||||
}
|
||||
|
||||
export function TitleBar({
|
||||
@@ -58,7 +59,8 @@ export function TitleBar({
|
||||
onToggleDevtools,
|
||||
onOpenAbout,
|
||||
onCreatePlugin,
|
||||
onReloadPlugins
|
||||
onReloadPlugins,
|
||||
onOpenBuildSettings
|
||||
}: TitleBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
||||
@@ -152,7 +154,8 @@ export function TitleBar({
|
||||
documentation: 'Documentation',
|
||||
checkForUpdates: 'Check for Updates',
|
||||
about: 'About',
|
||||
devtools: 'Developer Tools'
|
||||
devtools: 'Developer Tools',
|
||||
buildSettings: 'Build Settings'
|
||||
},
|
||||
zh: {
|
||||
file: '文件',
|
||||
@@ -187,7 +190,8 @@ export function TitleBar({
|
||||
documentation: '文档',
|
||||
checkForUpdates: '检查更新',
|
||||
about: '关于',
|
||||
devtools: '开发者工具'
|
||||
devtools: '开发者工具',
|
||||
buildSettings: '构建设置'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || key;
|
||||
@@ -201,6 +205,8 @@ export function TitleBar({
|
||||
{ label: t('saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene },
|
||||
{ label: t('saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs },
|
||||
{ separator: true },
|
||||
{ label: t('buildSettings'), shortcut: 'Ctrl+Shift+B', onClick: onOpenBuildSettings },
|
||||
{ separator: true },
|
||||
{ label: t('openProject'), onClick: onOpenProject },
|
||||
{ label: t('closeProject'), onClick: onCloseProject },
|
||||
{ separator: true },
|
||||
|
||||
@@ -17,69 +17,139 @@ import { open } from '@tauri-apps/plugin-shell';
|
||||
import { RuntimeResolver } from '../services/RuntimeResolver';
|
||||
import { QRCodeDialog } from './QRCodeDialog';
|
||||
|
||||
// Generate runtime HTML for browser preview
|
||||
function generateRuntimeHtml(): string {
|
||||
import type { ModuleManifest } from '../services/RuntimeResolver';
|
||||
|
||||
/**
|
||||
* Generate runtime HTML for browser preview using ES Modules with import maps
|
||||
* 使用 ES 模块和 import maps 生成浏览器预览的运行时 HTML
|
||||
*
|
||||
* This matches the structure of published builds for consistency
|
||||
* 这与发布构建的结构一致
|
||||
*/
|
||||
function generateRuntimeHtml(importMap: Record<string, string>, modules: ModuleManifest[]): string {
|
||||
const importMapScript = `<script type="importmap">
|
||||
${JSON.stringify({ imports: importMap }, null, 2).split('\n').join('\n ')}
|
||||
</script>`;
|
||||
|
||||
// Generate plugin import code for modules with pluginExport
|
||||
// Only modules with pluginExport are considered runtime plugins
|
||||
// Core/infrastructure modules don't need to be registered as plugins
|
||||
const pluginModules = modules.filter(m => m.pluginExport);
|
||||
|
||||
const pluginImportCode = pluginModules.map(m =>
|
||||
` try {
|
||||
const { ${m.pluginExport} } = await import('@esengine/${m.id}');
|
||||
runtime.registerPlugin(${m.pluginExport});
|
||||
} catch (e) {
|
||||
console.warn('[Preview] Failed to load plugin ${m.id}:', e.message);
|
||||
}`
|
||||
).join('\n');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>ECS Runtime Preview</title>
|
||||
${importMapScript}
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
background: #1e1e1e;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; }
|
||||
#game-canvas { width: 100%; height: 100%; display: block; }
|
||||
#loading {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
background: #1a1a2e; color: #eee; font-family: sans-serif;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
#loading .spinner {
|
||||
width: 40px; height: 40px; border: 3px solid #333;
|
||||
border-top-color: #4a9eff; border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
#loading .message { margin-top: 16px; font-size: 14px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#error {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
display: none; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
background: #1a1a2e; color: #ff6b6b; font-family: sans-serif;
|
||||
padding: 20px; text-align: center;
|
||||
}
|
||||
#error.show { display: flex; }
|
||||
#error h2 { margin-bottom: 16px; }
|
||||
#error pre {
|
||||
background: rgba(0,0,0,0.3); padding: 16px; border-radius: 8px;
|
||||
max-width: 600px; white-space: pre-wrap; word-break: break-word;
|
||||
font-size: 13px; line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="runtime-canvas"></canvas>
|
||||
<script src="/runtime.browser.js"></script>
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
<div class="message" id="loading-message">Loading...</div>
|
||||
</div>
|
||||
<div id="error">
|
||||
<h2 id="error-title">Failed to start</h2>
|
||||
<pre id="error-message"></pre>
|
||||
</div>
|
||||
<canvas id="game-canvas"></canvas>
|
||||
|
||||
<script type="module">
|
||||
import * as esEngine from '/es_engine.js';
|
||||
(async function() {
|
||||
try {
|
||||
// Set canvas size before creating runtime
|
||||
const canvas = document.getElementById('runtime-canvas');
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
const loading = document.getElementById('loading');
|
||||
const loadingMessage = document.getElementById('loading-message');
|
||||
const errorDiv = document.getElementById('error');
|
||||
const errorTitle = document.getElementById('error-title');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
|
||||
const runtime = ECSRuntime.create({
|
||||
canvasId: 'runtime-canvas',
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
projectConfigUrl: '/ecs-editor.config.json'
|
||||
});
|
||||
function showError(title, msg) {
|
||||
loading.style.display = 'none';
|
||||
errorTitle.textContent = title || 'Failed to start';
|
||||
errorMessage.textContent = msg;
|
||||
errorDiv.classList.add('show');
|
||||
console.error('[Preview]', msg);
|
||||
}
|
||||
|
||||
await runtime.initialize(esEngine);
|
||||
await runtime.loadScene('/scene.json?_=' + Date.now());
|
||||
runtime.start();
|
||||
function updateLoading(msg) {
|
||||
loadingMessage.textContent = msg;
|
||||
console.log('[Preview]', msg);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const canvas = document.getElementById('runtime-canvas');
|
||||
const newWidth = window.innerWidth;
|
||||
const newHeight = window.innerHeight;
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
runtime.handleResize(newWidth, newHeight);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Runtime error:', e);
|
||||
}
|
||||
})();
|
||||
try {
|
||||
updateLoading('Loading runtime...');
|
||||
const ECSRuntime = (await import('@esengine/platform-web')).default;
|
||||
|
||||
updateLoading('Loading WASM module...');
|
||||
const wasmModule = await import('./libs/es-engine/es_engine.js');
|
||||
|
||||
updateLoading('Initializing runtime...');
|
||||
const runtime = ECSRuntime.create({
|
||||
canvasId: 'game-canvas',
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
assetBaseUrl: './assets',
|
||||
projectConfigUrl: './ecs-editor.config.json'
|
||||
});
|
||||
|
||||
updateLoading('Loading plugins...');
|
||||
${pluginImportCode}
|
||||
|
||||
await runtime.initialize(wasmModule);
|
||||
|
||||
updateLoading('Loading scene...');
|
||||
await runtime.loadScene('./scene.json?_=' + Date.now());
|
||||
|
||||
loading.style.display = 'none';
|
||||
runtime.start();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
runtime.handleResize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
console.log('[Preview] Started successfully');
|
||||
} catch (error) {
|
||||
showError(null, error.message || String(error));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -697,13 +767,13 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
await TauriAPI.createDirectory(runtimeDir);
|
||||
}
|
||||
|
||||
// Use RuntimeResolver to copy runtime files
|
||||
// 使用 RuntimeResolver 复制运行时文件
|
||||
// Use RuntimeResolver to copy runtime files with ES Modules structure
|
||||
// 使用 RuntimeResolver 复制运行时文件(ES 模块结构)
|
||||
const runtimeResolver = RuntimeResolver.getInstance();
|
||||
await runtimeResolver.initialize();
|
||||
await runtimeResolver.prepareRuntimeFiles(runtimeDir);
|
||||
const { modules, importMap } = await runtimeResolver.prepareRuntimeFiles(runtimeDir);
|
||||
|
||||
// Write scene data and HTML (always update)
|
||||
// Write scene data
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneData);
|
||||
|
||||
// Copy project config file (for plugin settings)
|
||||
@@ -818,7 +888,8 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/asset-catalog.json`, JSON.stringify(assetCatalog, null, 2));
|
||||
console.log(`[Viewport] Asset catalog created with ${Object.keys(catalogEntries).length} entries`);
|
||||
|
||||
const runtimeHtml = generateRuntimeHtml();
|
||||
// Generate HTML with import maps (matching published build structure)
|
||||
const runtimeHtml = generateRuntimeHtml(importMap, modules);
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, runtimeHtml);
|
||||
|
||||
// Start local server and open browser
|
||||
@@ -865,10 +936,10 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
await TauriAPI.createDirectory(runtimeDir);
|
||||
}
|
||||
|
||||
// Use RuntimeResolver to copy runtime files
|
||||
// Use RuntimeResolver to copy runtime files with ES Modules structure
|
||||
const runtimeResolver = RuntimeResolver.getInstance();
|
||||
await runtimeResolver.initialize();
|
||||
await runtimeResolver.prepareRuntimeFiles(runtimeDir);
|
||||
const { modules, importMap } = await runtimeResolver.prepareRuntimeFiles(runtimeDir);
|
||||
|
||||
// Copy project config file (for plugin settings)
|
||||
const projectService = Core.services.tryResolve(ProjectService);
|
||||
@@ -883,10 +954,10 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Write scene data and HTML
|
||||
// Write scene data and HTML with import maps
|
||||
const sceneDataStr = typeof sceneData === 'string' ? sceneData : new TextDecoder().decode(sceneData);
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneDataStr);
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, generateRuntimeHtml());
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, generateRuntimeHtml(importMap, modules));
|
||||
|
||||
// Copy textures referenced in scene
|
||||
const assetsDir = `${runtimeDir}\\assets`;
|
||||
|
||||
@@ -99,7 +99,11 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
'conf',
|
||||
'log',
|
||||
'btree',
|
||||
'ecs'
|
||||
'ecs',
|
||||
'mat',
|
||||
'shader',
|
||||
'tilemap',
|
||||
'tileset'
|
||||
];
|
||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif'];
|
||||
const isTextFile = fileInfo.extension && textExtensions.includes(fileInfo.extension.toLowerCase());
|
||||
@@ -188,6 +192,12 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
}
|
||||
|
||||
if (target.type === 'asset-file') {
|
||||
// Check if a plugin provides a custom inspector for this asset type
|
||||
const customInspector = inspectorRegistry.render(target, { target, projectPath });
|
||||
if (customInspector) {
|
||||
return customInspector;
|
||||
}
|
||||
// Fall back to default asset file inspector
|
||||
return <AssetFileInspector fileInfo={target.data} content={target.content} isImage={target.isImage} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,16 +59,26 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[];
|
||||
|
||||
// 当 entity 变化或组件数量变化时,更新展开状态(新组件默认展开)
|
||||
// 注意:不要依赖 componentVersion,否则每次属性变化都会重置展开状态
|
||||
useEffect(() => {
|
||||
setExpandedComponents((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
// 添加所有当前组件的索引(保留已有的展开状态)
|
||||
// 只添加新增组件的索引(保留已有的展开/收缩状态)
|
||||
entity.components.forEach((_, index) => {
|
||||
newSet.add(index);
|
||||
// 只有当索引不在集合中时才添加(即新组件)
|
||||
if (!prev.has(index) && index >= prev.size) {
|
||||
newSet.add(index);
|
||||
}
|
||||
});
|
||||
// 移除不存在的索引(组件被删除的情况)
|
||||
for (const idx of prev) {
|
||||
if (idx >= entity.components.length) {
|
||||
newSet.delete(idx);
|
||||
}
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, [entity, entity.components.length, componentVersion]);
|
||||
}, [entity, entity.components.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showComponentMenu && addButtonRef.current) {
|
||||
@@ -439,6 +449,15 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
onAction={handlePropertyAction}
|
||||
/>
|
||||
}
|
||||
{/* Append-mode inspectors (shown after default inspector) */}
|
||||
{componentInspectorRegistry?.renderAppendInspectors({
|
||||
component,
|
||||
entity,
|
||||
version: componentVersion + localVersion,
|
||||
onChange: (propName: string, value: unknown) =>
|
||||
handlePropertyChange(component, propName, value),
|
||||
onAction: handlePropertyAction
|
||||
})}
|
||||
{/* Dynamic component actions from plugins */}
|
||||
{componentActionRegistry?.getActionsForComponent(componentName).map((action) => {
|
||||
// 解析图标:支持字符串(Lucide 图标名)或 React 元素
|
||||
|
||||
187
packages/editor-app/src/components/styles/ModuleListSetting.css
Normal file
187
packages/editor-app/src/components/styles/ModuleListSetting.css
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Module List Setting Styles.
|
||||
* 模块列表设置样式。
|
||||
*/
|
||||
|
||||
.module-list-setting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.module-list-categories {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Category Group */
|
||||
.module-category-group {
|
||||
border: 1px solid var(--border-color, #3a3a3a);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.module-category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary, #2a2a2a);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.module-category-header:hover {
|
||||
background: var(--bg-hover, #333);
|
||||
}
|
||||
|
||||
.module-category-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #eee);
|
||||
}
|
||||
|
||||
.module-category-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #888);
|
||||
}
|
||||
|
||||
/* Category Items */
|
||||
.module-category-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.module-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px 6px 28px;
|
||||
border-top: 1px solid var(--border-color, #3a3a3a);
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.module-item:hover {
|
||||
background: var(--bg-hover, #333);
|
||||
}
|
||||
|
||||
.module-item.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Checkbox Label */
|
||||
.module-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.module-checkbox-label input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.module-checkbox-label input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.module-icon {
|
||||
color: var(--text-secondary, #aaa);
|
||||
}
|
||||
|
||||
.module-item.enabled .module-icon {
|
||||
color: var(--accent-color, #4a9eff);
|
||||
}
|
||||
|
||||
.module-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #eee);
|
||||
}
|
||||
|
||||
.module-badge {
|
||||
padding: 1px 6px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.module-badge.core {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
color: var(--accent-color, #4a9eff);
|
||||
}
|
||||
|
||||
.module-size {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary, #888);
|
||||
}
|
||||
|
||||
.module-size-inlined {
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.module-wasm-indicator {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.module-size-breakdown {
|
||||
margin-left: 4px;
|
||||
opacity: 0.7;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.module-list-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary, #2a2a2a);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.module-list-size-label {
|
||||
color: var(--text-secondary, #aaa);
|
||||
}
|
||||
|
||||
.module-list-size-value {
|
||||
color: var(--text-primary, #eee);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Validation Error */
|
||||
.module-validation-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 107, 107, 0.15);
|
||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #ff6b6b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.module-validation-error button {
|
||||
margin-left: auto;
|
||||
padding: 2px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 3px;
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.module-validation-error button:hover {
|
||||
background: rgba(255, 107, 107, 0.2);
|
||||
}
|
||||
@@ -50,7 +50,12 @@
|
||||
"core": "Core",
|
||||
"rendering": "Rendering",
|
||||
"physics": "Physics",
|
||||
"audio": "Audio"
|
||||
"audio": "Audio",
|
||||
"tilemap": "Tilemap"
|
||||
},
|
||||
"material": {
|
||||
"name": "Material",
|
||||
"description": "Custom material and shader component"
|
||||
},
|
||||
"transform": {
|
||||
"description": "Transform - Position, Rotation, Scale"
|
||||
@@ -76,5 +81,16 @@
|
||||
"audioSource": {
|
||||
"description": "Audio Source"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"create": {
|
||||
"material": "Material",
|
||||
"shader": "Shader"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"create": {
|
||||
"materialEntity": "Material Entity"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,12 @@
|
||||
"core": "基础",
|
||||
"rendering": "渲染",
|
||||
"physics": "物理",
|
||||
"audio": "音频"
|
||||
"audio": "音频",
|
||||
"tilemap": "瓦片地图"
|
||||
},
|
||||
"material": {
|
||||
"name": "材质",
|
||||
"description": "自定义材质和着色器组件"
|
||||
},
|
||||
"transform": {
|
||||
"description": "变换组件 - 位置、旋转、缩放"
|
||||
@@ -76,5 +81,16 @@
|
||||
"audioSource": {
|
||||
"description": "音频源组件"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"create": {
|
||||
"material": "材质",
|
||||
"shader": "着色器"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"create": {
|
||||
"materialEntity": "材质实体"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
import type { IPlugin, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
import type { IPlugin, IEditorModuleLoader, ModuleManifest } from '@esengine/editor-core';
|
||||
import { SettingsRegistry } from '@esengine/editor-core';
|
||||
import { SettingsService } from '../../services/SettingsService';
|
||||
|
||||
@@ -102,27 +102,23 @@ class EditorAppearanceEditorModule implements IEditorModuleLoader {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/editor-appearance',
|
||||
name: 'Editor Appearance',
|
||||
name: '@esengine/editor-appearance',
|
||||
displayName: 'Editor Appearance',
|
||||
version: '1.0.0',
|
||||
description: 'Configure editor appearance settings',
|
||||
category: 'tools',
|
||||
category: 'Other',
|
||||
icon: 'Palette',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
isCore: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'EditorAppearanceEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'earliest'
|
||||
}
|
||||
]
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
canContainContent: false,
|
||||
dependencies: [],
|
||||
exports: {}
|
||||
};
|
||||
|
||||
export const EditorAppearancePlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
editorModule: new EditorAppearanceEditorModule()
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type { IPlugin, IEditorModuleLoader, PluginDescriptor, GizmoProviderRegistration } from '@esengine/editor-core';
|
||||
import type { IPlugin, IEditorModuleLoader, ModuleManifest, GizmoProviderRegistration } from '@esengine/editor-core';
|
||||
import { registerSpriteGizmo } from '../../gizmos';
|
||||
|
||||
/**
|
||||
@@ -24,27 +24,25 @@ class GizmoEditorModule implements IEditorModuleLoader {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/gizmo',
|
||||
name: 'Gizmo System',
|
||||
name: '@esengine/gizmo',
|
||||
displayName: 'Gizmo System',
|
||||
version: '1.0.0',
|
||||
description: 'Provides gizmo support for editor components',
|
||||
category: 'tools',
|
||||
category: 'Other',
|
||||
icon: 'Move',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
isCore: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'GizmoEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'preDefault'
|
||||
}
|
||||
]
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
canContainContent: false,
|
||||
dependencies: ['engine-core'],
|
||||
exports: {
|
||||
other: ['GizmoRegistry']
|
||||
}
|
||||
};
|
||||
|
||||
export const GizmoPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
editorModule: new GizmoEditorModule()
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
import type { IPlugin, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
import type { IPlugin, IEditorModuleLoader, ModuleManifest } from '@esengine/editor-core';
|
||||
import { SettingsRegistry } from '@esengine/editor-core';
|
||||
|
||||
const logger = createLogger('PluginConfigPlugin');
|
||||
@@ -51,27 +51,23 @@ class PluginConfigEditorModule implements IEditorModuleLoader {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/plugin-config',
|
||||
name: 'Plugin Config',
|
||||
name: '@esengine/plugin-config',
|
||||
displayName: 'Plugin Config',
|
||||
version: '1.0.0',
|
||||
description: 'Configure engine plugins',
|
||||
category: 'tools',
|
||||
category: 'Other',
|
||||
icon: 'Package',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
isCore: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'PluginConfigEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'postDefault'
|
||||
}
|
||||
]
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
canContainContent: false,
|
||||
dependencies: [],
|
||||
exports: {}
|
||||
};
|
||||
|
||||
export const PluginConfigPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
editorModule: new PluginConfigEditorModule()
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IPlugin,
|
||||
IEditorModuleLoader,
|
||||
PluginDescriptor,
|
||||
ModuleManifest,
|
||||
MenuItemDescriptor
|
||||
} from '@esengine/editor-core';
|
||||
import { MessageHub, SettingsRegistry } from '@esengine/editor-core';
|
||||
@@ -114,26 +114,23 @@ class ProfilerEditorModule implements IEditorModuleLoader {
|
||||
async onEditorReady(): Promise<void> {}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/profiler',
|
||||
name: 'Performance Profiler',
|
||||
name: '@esengine/profiler',
|
||||
displayName: 'Performance Profiler',
|
||||
version: '1.0.0',
|
||||
description: 'Real-time performance monitoring for ECS systems',
|
||||
category: 'tools',
|
||||
category: 'Other',
|
||||
icon: 'BarChart3',
|
||||
enabledByDefault: true,
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'ProfilerEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'postDefault'
|
||||
}
|
||||
]
|
||||
dependencies: [],
|
||||
exports: {}
|
||||
};
|
||||
|
||||
export const ProfilerPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
editorModule: new ProfilerEditorModule()
|
||||
};
|
||||
|
||||
@@ -8,10 +8,25 @@
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { createLogger, Core } from '@esengine/ecs-framework';
|
||||
import type { IPlugin, IEditorModuleLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
import { SettingsRegistry, ProjectService } from '@esengine/editor-core';
|
||||
import type { IPlugin, IEditorModuleLoader, ModuleManifest } from '@esengine/editor-core';
|
||||
import { SettingsRegistry, ProjectService, moduleRegistry } from '@esengine/editor-core';
|
||||
import EngineService from '../../services/EngineService';
|
||||
|
||||
/**
|
||||
* Get engine modules from ModuleRegistry.
|
||||
* 从 ModuleRegistry 获取引擎模块。
|
||||
*
|
||||
* Returns all registered modules from the module registry.
|
||||
* 返回模块注册表中的所有已注册模块。
|
||||
*/
|
||||
function getModuleManifests(): ModuleManifest[] {
|
||||
// Get modules from moduleRegistry singleton
|
||||
// 从 moduleRegistry 单例获取模块
|
||||
const modules = moduleRegistry.getAllModules();
|
||||
console.log('[ProjectSettingsPlugin] getModuleManifests: got', modules.length, 'modules from registry');
|
||||
return modules;
|
||||
}
|
||||
|
||||
const logger = createLogger('ProjectSettingsPlugin');
|
||||
|
||||
/**
|
||||
@@ -85,6 +100,38 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader {
|
||||
}))
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'modules',
|
||||
title: '引擎模块',
|
||||
description: '管理项目使用的引擎模块。每个模块包含运行时组件和编辑器工具。禁用不需要的模块可以减小构建体积。',
|
||||
settings: [
|
||||
{
|
||||
key: 'project.disabledModules',
|
||||
label: '模块列表',
|
||||
type: 'moduleList',
|
||||
// Default: no modules disabled (all enabled)
|
||||
// 默认:没有禁用的模块(全部启用)
|
||||
defaultValue: [],
|
||||
description: '取消勾选不需要的模块。核心模块不能禁用。新增的模块会自动启用。',
|
||||
// Custom props for moduleList type
|
||||
// Modules are loaded dynamically from ModuleRegistry (sizes from module.json)
|
||||
// 模块从 ModuleRegistry 动态加载(大小来自 module.json)
|
||||
getModules: getModuleManifests,
|
||||
// Use blacklist mode: store disabled modules instead of enabled
|
||||
// 使用黑名单模式:存储禁用的模块而不是启用的
|
||||
useBlacklist: true,
|
||||
validateDisable: async (moduleId: string) => {
|
||||
// Use moduleRegistry singleton for validation
|
||||
// 使用 moduleRegistry 单例进行验证
|
||||
const validation = await moduleRegistry.validateDisable(moduleId);
|
||||
if (!validation.canDisable) {
|
||||
return { canDisable: false, reason: validation.message };
|
||||
}
|
||||
return { canDisable: true };
|
||||
}
|
||||
} as any // Cast to any to allow custom props
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -147,27 +194,23 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/project-settings',
|
||||
name: 'Project Settings',
|
||||
name: '@esengine/project-settings',
|
||||
displayName: 'Project Settings',
|
||||
version: '1.0.0',
|
||||
description: 'Configure project-level settings',
|
||||
category: 'tools',
|
||||
category: 'Other',
|
||||
icon: 'Settings',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
isCore: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'ProjectSettingsEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'postDefault'
|
||||
}
|
||||
]
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
canContainContent: false,
|
||||
dependencies: [],
|
||||
exports: {}
|
||||
};
|
||||
|
||||
export const ProjectSettingsPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
editorModule: new ProjectSettingsEditorModule()
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IPlugin,
|
||||
IEditorModuleLoader,
|
||||
PluginDescriptor,
|
||||
ModuleManifest,
|
||||
PanelDescriptor,
|
||||
MenuItemDescriptor,
|
||||
ToolbarItemDescriptor,
|
||||
@@ -173,27 +173,25 @@ class SceneInspectorEditorModule implements IEditorModuleLoader {
|
||||
async onProjectClose(): Promise<void> {}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/scene-inspector',
|
||||
name: 'Scene Inspector',
|
||||
name: '@esengine/scene-inspector',
|
||||
displayName: 'Scene Inspector',
|
||||
version: '1.0.0',
|
||||
description: 'Scene hierarchy and entity inspector',
|
||||
category: 'tools',
|
||||
category: 'Other',
|
||||
icon: 'Search',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
isCore: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'SceneInspectorEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'default'
|
||||
}
|
||||
]
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
canContainContent: false,
|
||||
dependencies: ['engine-core'],
|
||||
exports: {
|
||||
other: ['SceneHierarchy', 'EntityInspector']
|
||||
}
|
||||
};
|
||||
|
||||
export const SceneInspectorPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
editorModule: new SceneInspectorEditorModule()
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user