feat(editor): 完善用户代码热更新和环境检测 (#275)
* fix: 更新 bundle-runtime 脚本使用正确的 platform-web 输出文件 原脚本引用的 runtime.browser.js 不存在,改为使用 index.mjs * feat(editor): 完善用户代码热更新和环境检测 ## 热更新改进 - 添加 hotReloadInstances() 方法,通过更新原型链实现真正的热更新 - 组件实例保留数据,仅更新方法 - ComponentRegistry 支持热更新时替换同名组件类 ## 环境检测 - 启动时检测 esbuild 可用性 - 在启动页面底部显示环境状态指示器 - 添加 check_environment Rust 命令和前端 API ## esbuild 打包 - 将 esbuild 二进制文件打包到应用中 - 用户无需全局安装 esbuild - 支持 Windows/macOS/Linux 平台 ## 文件监视优化 - 添加 300ms 防抖,避免重复编译 - 修复路径分隔符混合问题 ## 资源打包修复 - 修复 Tauri 资源配置,保留 engine 目录结构 - 添加 src-tauri/bin/ 和 src-tauri/engine/ 到 gitignore * fix: 将热更新模式改为可选,修复测试失败 - ComponentRegistry 添加 hotReloadEnabled 标志,默认禁用 - 只有启用热更新模式时才会替换同名组件类 - 编辑器环境自动启用热更新模式 - reset() 方法中重置热更新标志 * test: 添加热更新模式的测试用例
This commit is contained in:
@@ -21,6 +21,14 @@ export class ComponentRegistry {
|
||||
private static maskCache = new Map<string, BitMask64Data>();
|
||||
private static nextBitIndex = 0;
|
||||
|
||||
/**
|
||||
* 热更新模式标志,默认禁用
|
||||
* Hot reload mode flag, disabled by default
|
||||
* 编辑器环境应启用此选项以支持脚本热更新
|
||||
* Editor environment should enable this to support script hot reload
|
||||
*/
|
||||
private static hotReloadEnabled = false;
|
||||
|
||||
/**
|
||||
* 注册组件类型并分配位掩码
|
||||
* @param componentType 组件类型
|
||||
@@ -34,11 +42,27 @@ export class ComponentRegistry {
|
||||
return existingIndex;
|
||||
}
|
||||
|
||||
// 检查是否有同名但不同类的组件已注册
|
||||
if (this.componentNameToType.has(typeName)) {
|
||||
// 检查是否有同名但不同类的组件已注册(热更新场景)
|
||||
// Check if a component with the same name but different class is registered (hot reload scenario)
|
||||
if (this.hotReloadEnabled && this.componentNameToType.has(typeName)) {
|
||||
const existingType = this.componentNameToType.get(typeName);
|
||||
if (existingType !== componentType) {
|
||||
console.warn(`[ComponentRegistry] Component name conflict: "${typeName}" already registered with different class. Existing: ${existingType?.name}, New: ${componentType.name}`);
|
||||
// 热更新:替换旧的类为新的类,复用相同的 bitIndex
|
||||
// Hot reload: replace old class with new class, reuse the same bitIndex
|
||||
const existingIndex = this.componentTypes.get(existingType!)!;
|
||||
|
||||
// 移除旧类的映射
|
||||
// Remove old class mapping
|
||||
this.componentTypes.delete(existingType!);
|
||||
|
||||
// 用新类更新映射
|
||||
// Update mappings with new class
|
||||
this.componentTypes.set(componentType, existingIndex);
|
||||
this.bitIndexToType.set(existingIndex, componentType);
|
||||
this.componentNameToType.set(typeName, componentType);
|
||||
|
||||
console.log(`[ComponentRegistry] Hot reload: replaced component "${typeName}"`);
|
||||
return existingIndex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +233,32 @@ export class ComponentRegistry {
|
||||
this.maskCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用热更新模式
|
||||
* Enable hot reload mode
|
||||
* 在编辑器环境中调用以支持脚本热更新
|
||||
* Call in editor environment to support script hot reload
|
||||
*/
|
||||
public static enableHotReload(): void {
|
||||
this.hotReloadEnabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用热更新模式
|
||||
* Disable hot reload mode
|
||||
*/
|
||||
public static disableHotReload(): void {
|
||||
this.hotReloadEnabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查热更新模式是否启用
|
||||
* Check if hot reload mode is enabled
|
||||
*/
|
||||
public static isHotReloadEnabled(): boolean {
|
||||
return this.hotReloadEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置注册表(用于测试)
|
||||
*/
|
||||
@@ -219,5 +269,6 @@ export class ComponentRegistry {
|
||||
this.componentNameToId.clear();
|
||||
this.maskCache.clear();
|
||||
this.nextBitIndex = 0;
|
||||
this.hotReloadEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,103 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('热更新模式', () => {
|
||||
it('默认应该禁用热更新模式', () => {
|
||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('应该能够启用和禁用热更新模式', () => {
|
||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(false);
|
||||
|
||||
ComponentRegistry.enableHotReload();
|
||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(true);
|
||||
|
||||
ComponentRegistry.disableHotReload();
|
||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('reset 应该重置热更新模式为禁用', () => {
|
||||
ComponentRegistry.enableHotReload();
|
||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(true);
|
||||
|
||||
ComponentRegistry.reset();
|
||||
expect(ComponentRegistry.isHotReloadEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('启用热更新时应该替换同名组件类', () => {
|
||||
ComponentRegistry.enableHotReload();
|
||||
|
||||
// 模拟热更新场景:两个不同的类但有相同的 constructor.name
|
||||
// Simulate hot reload: two different classes with same constructor.name
|
||||
const createHotReloadComponent = (version: number) => {
|
||||
// 使用 eval 创建具有相同名称的类
|
||||
// Use function constructor to create classes with same name
|
||||
const cls = (new Function('Component', `
|
||||
return class HotReloadTestComponent extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.version = ${version};
|
||||
}
|
||||
}
|
||||
`))(Component);
|
||||
return cls;
|
||||
};
|
||||
|
||||
const TestComponentV1 = createHotReloadComponent(1);
|
||||
const TestComponentV2 = createHotReloadComponent(2);
|
||||
|
||||
// 确保两个类名相同但不是同一个类
|
||||
expect(TestComponentV1.name).toBe(TestComponentV2.name);
|
||||
expect(TestComponentV1).not.toBe(TestComponentV2);
|
||||
|
||||
const index1 = ComponentRegistry.register(TestComponentV1);
|
||||
const index2 = ComponentRegistry.register(TestComponentV2);
|
||||
|
||||
// 应该复用相同的 bitIndex
|
||||
expect(index1).toBe(index2);
|
||||
|
||||
// 新类应该替换旧类
|
||||
expect(ComponentRegistry.isRegistered(TestComponentV2)).toBe(true);
|
||||
expect(ComponentRegistry.isRegistered(TestComponentV1)).toBe(false);
|
||||
});
|
||||
|
||||
it('禁用热更新时不应该替换同名组件类', () => {
|
||||
// 确保热更新被禁用
|
||||
ComponentRegistry.disableHotReload();
|
||||
|
||||
// 创建两个同名组件
|
||||
// Create two classes with same constructor.name
|
||||
const createNoHotReloadComponent = (id: number) => {
|
||||
const cls = (new Function('Component', `
|
||||
return class NoHotReloadComponent extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.id = ${id};
|
||||
}
|
||||
}
|
||||
`))(Component);
|
||||
return cls;
|
||||
};
|
||||
|
||||
const TestCompA = createNoHotReloadComponent(1);
|
||||
const TestCompB = createNoHotReloadComponent(2);
|
||||
|
||||
// 确保两个类名相同但不是同一个类
|
||||
expect(TestCompA.name).toBe(TestCompB.name);
|
||||
expect(TestCompA).not.toBe(TestCompB);
|
||||
|
||||
const index1 = ComponentRegistry.register(TestCompA);
|
||||
const index2 = ComponentRegistry.register(TestCompB);
|
||||
|
||||
// 应该分配不同的 bitIndex(因为热更新被禁用)
|
||||
expect(index2).toBe(index1 + 1);
|
||||
|
||||
// 两个类都应该被注册
|
||||
expect(ComponentRegistry.isRegistered(TestCompA)).toBe(true);
|
||||
expect(ComponentRegistry.isRegistered(TestCompB)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
it('应该正确处理第 64 个组件(边界)', () => {
|
||||
const scene = new Scene();
|
||||
|
||||
2
packages/editor-app/.gitignore
vendored
2
packages/editor-app/.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
# Generated runtime files
|
||||
src-tauri/runtime/
|
||||
src-tauri/engine/
|
||||
src-tauri/bin/
|
||||
|
||||
@@ -22,10 +22,11 @@ if (!fs.existsSync(bundleDir)) {
|
||||
}
|
||||
|
||||
// Files to bundle
|
||||
// 需要打包的文件
|
||||
const filesToBundle = [
|
||||
{
|
||||
src: path.join(rootPath, 'packages/platform-web/dist/runtime.browser.js'),
|
||||
dst: path.join(bundleDir, 'runtime.browser.js')
|
||||
src: path.join(rootPath, 'packages/platform-web/dist/index.mjs'),
|
||||
dst: path.join(bundleDir, 'platform-web.mjs')
|
||||
},
|
||||
{
|
||||
src: path.join(rootPath, 'packages/engine/pkg/es_engine_bg.wasm'),
|
||||
@@ -96,34 +97,101 @@ for (const { src, dst } of typeFilesToBundle) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update tauri.conf.json to include runtime directory
|
||||
if (success) {
|
||||
const tauriConfigPath = path.join(editorPath, 'src-tauri', 'tauri.conf.json');
|
||||
const config = JSON.parse(fs.readFileSync(tauriConfigPath, 'utf8'));
|
||||
// Copy engine modules directory from dist/engine to src-tauri/engine
|
||||
// 复制引擎模块目录从 dist/engine 到 src-tauri/engine
|
||||
const engineSrcDir = path.join(editorPath, 'dist', 'engine');
|
||||
const engineDstDir = path.join(editorPath, 'src-tauri', 'engine');
|
||||
|
||||
// Add runtime directory to resources
|
||||
if (!config.bundle) {
|
||||
config.bundle = {};
|
||||
}
|
||||
if (!config.bundle.resources) {
|
||||
config.bundle.resources = {};
|
||||
/**
|
||||
* Recursively copy directory
|
||||
* 递归复制目录
|
||||
*/
|
||||
function copyDirRecursive(src, dst) {
|
||||
if (!fs.existsSync(src)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle both array and object format for resources
|
||||
if (Array.isArray(config.bundle.resources)) {
|
||||
if (!config.bundle.resources.includes('runtime/**/*')) {
|
||||
config.bundle.resources.push('runtime/**/*');
|
||||
fs.writeFileSync(tauriConfigPath, JSON.stringify(config, null, 2));
|
||||
console.log('✓ Updated tauri.conf.json with runtime resources');
|
||||
}
|
||||
} else if (typeof config.bundle.resources === 'object') {
|
||||
// Object format - add runtime files if not present
|
||||
if (!config.bundle.resources['runtime/**/*']) {
|
||||
config.bundle.resources['runtime/**/*'] = '.';
|
||||
fs.writeFileSync(tauriConfigPath, JSON.stringify(config, null, 2));
|
||||
console.log('✓ Updated tauri.conf.json with runtime resources');
|
||||
if (!fs.existsSync(dst)) {
|
||||
fs.mkdirSync(dst, { recursive: true });
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(src, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const dstPath = path.join(dst, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
copyDirRecursive(srcPath, dstPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, dstPath);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fs.existsSync(engineSrcDir)) {
|
||||
// Remove old engine directory if exists
|
||||
if (fs.existsSync(engineDstDir)) {
|
||||
fs.rmSync(engineDstDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (copyDirRecursive(engineSrcDir, engineDstDir)) {
|
||||
// Count files
|
||||
let fileCount = 0;
|
||||
function countFiles(dir) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
countFiles(path.join(dir, entry.name));
|
||||
} else {
|
||||
fileCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
countFiles(engineDstDir);
|
||||
console.log(`✓ Copied engine modules directory (${fileCount} files)`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Engine modules directory not found: ${engineSrcDir}`);
|
||||
console.log(' Build editor-app first: pnpm --filter @esengine/editor-app build');
|
||||
}
|
||||
|
||||
// Copy esbuild binary for user code compilation
|
||||
// 复制 esbuild 二进制文件用于用户代码编译
|
||||
const binDir = path.join(editorPath, 'src-tauri', 'bin');
|
||||
if (!fs.existsSync(binDir)) {
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
console.log(`Created bin directory: ${binDir}`);
|
||||
}
|
||||
|
||||
// Platform-specific esbuild binary paths
|
||||
// 平台特定的 esbuild 二进制路径
|
||||
const esbuildSources = {
|
||||
win32: path.join(rootPath, 'node_modules/@esbuild/win32-x64/esbuild.exe'),
|
||||
darwin: path.join(rootPath, 'node_modules/@esbuild/darwin-x64/bin/esbuild'),
|
||||
linux: path.join(rootPath, 'node_modules/@esbuild/linux-x64/bin/esbuild'),
|
||||
};
|
||||
|
||||
const platform = process.platform;
|
||||
const esbuildSrc = esbuildSources[platform];
|
||||
const esbuildDst = path.join(binDir, platform === 'win32' ? 'esbuild.exe' : 'esbuild');
|
||||
|
||||
if (esbuildSrc && fs.existsSync(esbuildSrc)) {
|
||||
try {
|
||||
fs.copyFileSync(esbuildSrc, esbuildDst);
|
||||
// Ensure executable permission on Unix
|
||||
if (platform !== 'win32') {
|
||||
fs.chmodSync(esbuildDst, 0o755);
|
||||
}
|
||||
const stats = fs.statSync(esbuildDst);
|
||||
console.log(`✓ Bundled esbuild binary (${(stats.size / 1024 / 1024).toFixed(2)} MB)`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to bundle esbuild: ${error.message}`);
|
||||
console.log(' User code compilation will require global esbuild installation');
|
||||
}
|
||||
} else {
|
||||
console.warn(`esbuild binary not found for platform ${platform}: ${esbuildSrc}`);
|
||||
console.log(' User code compilation will require global esbuild installation');
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
|
||||
@@ -67,6 +67,34 @@ pub struct CompileResult {
|
||||
pub output_path: Option<String>,
|
||||
}
|
||||
|
||||
/// Environment check result.
|
||||
/// 环境检测结果。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnvironmentCheckResult {
|
||||
/// Whether all required tools are available | 所有必需工具是否可用
|
||||
pub ready: bool,
|
||||
/// esbuild availability status | esbuild 可用性状态
|
||||
pub esbuild: ToolStatus,
|
||||
}
|
||||
|
||||
/// Tool availability status.
|
||||
/// 工具可用性状态。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ToolStatus {
|
||||
/// Whether the tool is available | 工具是否可用
|
||||
pub available: bool,
|
||||
/// Tool version (if available) | 工具版本(如果可用)
|
||||
pub version: Option<String>,
|
||||
/// Tool path (if available) | 工具路径(如果可用)
|
||||
pub path: Option<String>,
|
||||
/// Source of the tool: "bundled", "local", "global" | 工具来源
|
||||
pub source: Option<String>,
|
||||
/// Error message (if not available) | 错误信息(如果不可用)
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// File change event sent to frontend.
|
||||
/// 发送到前端的文件变更事件。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -78,6 +106,82 @@ pub struct FileChangeEvent {
|
||||
pub paths: Vec<String>,
|
||||
}
|
||||
|
||||
/// Check development environment.
|
||||
/// 检测开发环境。
|
||||
///
|
||||
/// Checks if all required tools (esbuild, etc.) are available.
|
||||
/// 检查所有必需的工具是否可用。
|
||||
#[command]
|
||||
pub async fn check_environment() -> Result<EnvironmentCheckResult, String> {
|
||||
let esbuild_status = check_esbuild_status();
|
||||
|
||||
Ok(EnvironmentCheckResult {
|
||||
ready: esbuild_status.available,
|
||||
esbuild: esbuild_status,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check esbuild availability and get its status.
|
||||
/// 检查 esbuild 可用性并获取其状态。
|
||||
fn check_esbuild_status() -> ToolStatus {
|
||||
// Try bundled esbuild first | 首先尝试打包的 esbuild
|
||||
if let Some(bundled_path) = find_bundled_esbuild() {
|
||||
match get_esbuild_version(&bundled_path) {
|
||||
Ok(version) => {
|
||||
return ToolStatus {
|
||||
available: true,
|
||||
version: Some(version),
|
||||
path: Some(bundled_path),
|
||||
source: Some("bundled".to_string()),
|
||||
error: None,
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
println!("[Environment] Bundled esbuild found but failed to get version: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try global esbuild | 尝试全局 esbuild
|
||||
let global_esbuild = if cfg!(windows) { "esbuild.cmd" } else { "esbuild" };
|
||||
match get_esbuild_version(global_esbuild) {
|
||||
Ok(version) => {
|
||||
ToolStatus {
|
||||
available: true,
|
||||
version: Some(version),
|
||||
path: Some(global_esbuild.to_string()),
|
||||
source: Some("global".to_string()),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
ToolStatus {
|
||||
available: false,
|
||||
version: None,
|
||||
path: None,
|
||||
source: None,
|
||||
error: Some("esbuild not found | 未找到 esbuild".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get esbuild version.
|
||||
/// 获取 esbuild 版本。
|
||||
fn get_esbuild_version(esbuild_path: &str) -> Result<String, String> {
|
||||
let output = Command::new(esbuild_path)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run esbuild: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
Ok(version)
|
||||
} else {
|
||||
Err("esbuild --version failed".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile TypeScript using esbuild.
|
||||
/// 使用 esbuild 编译 TypeScript。
|
||||
///
|
||||
@@ -254,6 +358,11 @@ pub async fn watch_scripts(
|
||||
|
||||
println!("[UserCode] Started watching: {}", watch_path_clone.display());
|
||||
|
||||
// Debounce state | 防抖状态
|
||||
let mut pending_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut last_event_time = std::time::Instant::now();
|
||||
let debounce_duration = Duration::from_millis(300);
|
||||
|
||||
// Event loop | 事件循环
|
||||
loop {
|
||||
// Check for shutdown | 检查关闭信号
|
||||
@@ -277,19 +386,28 @@ pub async fn watch_scripts(
|
||||
.collect();
|
||||
|
||||
if !ts_paths.is_empty() {
|
||||
let change_type = match event.kind {
|
||||
EventKind::Create(_) => "create",
|
||||
EventKind::Modify(_) => "modify",
|
||||
EventKind::Remove(_) => "remove",
|
||||
// Only handle create/modify/remove events | 只处理创建/修改/删除事件
|
||||
match event.kind {
|
||||
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
|
||||
// Add to pending paths and update last event time | 添加到待处理路径并更新最后事件时间
|
||||
for path in ts_paths {
|
||||
pending_paths.insert(path);
|
||||
}
|
||||
last_event_time = std::time::Instant::now();
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
// Check if we should emit pending events (debounce) | 检查是否应该发送待处理事件(防抖)
|
||||
if !pending_paths.is_empty() && last_event_time.elapsed() >= debounce_duration {
|
||||
let file_event = FileChangeEvent {
|
||||
change_type: change_type.to_string(),
|
||||
paths: ts_paths,
|
||||
change_type: "modify".to_string(),
|
||||
paths: pending_paths.drain().collect(),
|
||||
};
|
||||
|
||||
println!("[UserCode] File change detected: {:?}", file_event);
|
||||
println!("[UserCode] File change detected (debounced): {:?}", file_event);
|
||||
|
||||
// Emit event to frontend | 向前端发送事件
|
||||
if let Err(e) = app_clone.emit("user-code:file-changed", file_event) {
|
||||
@@ -297,9 +415,6 @@ pub async fn watch_scripts(
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
// No events, continue | 无事件,继续
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
println!("[UserCode] Watcher channel disconnected");
|
||||
break;
|
||||
@@ -352,10 +467,21 @@ pub async fn stop_watch_scripts(
|
||||
|
||||
/// Find esbuild executable path.
|
||||
/// 查找 esbuild 可执行文件路径。
|
||||
///
|
||||
/// Search order | 搜索顺序:
|
||||
/// 1. Bundled esbuild in app resources | 应用资源中打包的 esbuild
|
||||
/// 2. Local node_modules | 本地 node_modules
|
||||
/// 3. Global esbuild | 全局 esbuild
|
||||
fn find_esbuild(project_root: &str) -> Result<String, String> {
|
||||
let project_path = Path::new(project_root);
|
||||
|
||||
// Try local node_modules first | 首先尝试本地 node_modules
|
||||
// Try bundled esbuild first (in app resources) | 首先尝试打包的 esbuild(在应用资源中)
|
||||
if let Some(bundled) = find_bundled_esbuild() {
|
||||
println!("[Compiler] Using bundled esbuild: {}", bundled);
|
||||
return Ok(bundled);
|
||||
}
|
||||
|
||||
// Try local node_modules | 尝试本地 node_modules
|
||||
let local_esbuild = if cfg!(windows) {
|
||||
project_path.join("node_modules/.bin/esbuild.cmd")
|
||||
} else {
|
||||
@@ -363,6 +489,7 @@ fn find_esbuild(project_root: &str) -> Result<String, String> {
|
||||
};
|
||||
|
||||
if local_esbuild.exists() {
|
||||
println!("[Compiler] Using local esbuild: {}", local_esbuild.display());
|
||||
return Ok(local_esbuild.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
@@ -375,11 +502,51 @@ fn find_esbuild(project_root: &str) -> Result<String, String> {
|
||||
.output();
|
||||
|
||||
match check {
|
||||
Ok(output) if output.status.success() => Ok(global_esbuild.to_string()),
|
||||
Ok(output) if output.status.success() => {
|
||||
println!("[Compiler] Using global esbuild");
|
||||
Ok(global_esbuild.to_string())
|
||||
},
|
||||
_ => Err("esbuild not found. Please install esbuild: npm install -g esbuild | 未找到 esbuild,请安装: npm install -g esbuild".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Find bundled esbuild in app resources.
|
||||
/// 在应用资源中查找打包的 esbuild。
|
||||
fn find_bundled_esbuild() -> Option<String> {
|
||||
// Get the executable path | 获取可执行文件路径
|
||||
let exe_path = std::env::current_exe().ok()?;
|
||||
let exe_dir = exe_path.parent()?;
|
||||
|
||||
// In development, resources are in src-tauri directory | 开发模式下,资源在 src-tauri 目录
|
||||
// In production, resources are next to the executable | 生产模式下,资源在可执行文件旁边
|
||||
let esbuild_name = if cfg!(windows) { "esbuild.exe" } else { "esbuild" };
|
||||
|
||||
// Try production path (resources next to exe) | 尝试生产路径(资源在 exe 旁边)
|
||||
let prod_path = exe_dir.join("bin").join(esbuild_name);
|
||||
if prod_path.exists() {
|
||||
return Some(prod_path.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
// Try development path (in src-tauri/bin) | 尝试开发路径(在 src-tauri/bin 中)
|
||||
// This handles running via `cargo tauri dev`
|
||||
let dev_path = exe_dir
|
||||
.ancestors()
|
||||
.find_map(|p| {
|
||||
let candidate = p.join("src-tauri").join("bin").join(esbuild_name);
|
||||
if candidate.exists() {
|
||||
Some(candidate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(path) = dev_path {
|
||||
return Some(path.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse esbuild error output.
|
||||
/// 解析 esbuild 错误输出。
|
||||
fn parse_esbuild_errors(stderr: &str) -> Vec<CompileError> {
|
||||
|
||||
@@ -93,6 +93,7 @@ fn main() {
|
||||
commands::compile_typescript,
|
||||
commands::watch_scripts,
|
||||
commands::stop_watch_scripts,
|
||||
commands::check_environment,
|
||||
// Build commands | 构建命令
|
||||
commands::prepare_build_directory,
|
||||
commands::copy_directory,
|
||||
|
||||
@@ -11,9 +11,11 @@
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"resources": {
|
||||
"runtime/**/*": "."
|
||||
},
|
||||
"resources": [
|
||||
"runtime/**/*",
|
||||
"engine/**/*",
|
||||
"bin/*"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
@@ -357,6 +357,47 @@ export class TauriAPI {
|
||||
static convertFileSrc(filePath: string, protocol?: string): string {
|
||||
return convertFileSrc(filePath, protocol);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测开发环境
|
||||
* Check development environment
|
||||
*
|
||||
* Checks if all required tools (esbuild, etc.) are available.
|
||||
* 检查所有必需的工具是否可用。
|
||||
*
|
||||
* @returns 环境检测结果 | Environment check result
|
||||
*/
|
||||
static async checkEnvironment(): Promise<EnvironmentCheckResult> {
|
||||
return await invoke<EnvironmentCheckResult>('check_environment');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具可用性状态
|
||||
* Tool availability status
|
||||
*/
|
||||
export interface ToolStatus {
|
||||
/** 工具是否可用 | Whether the tool is available */
|
||||
available: boolean;
|
||||
/** 工具版本 | Tool version */
|
||||
version?: string;
|
||||
/** 工具路径 | Tool path */
|
||||
path?: string;
|
||||
/** 工具来源: "bundled", "local", "global" | Tool source */
|
||||
source?: string;
|
||||
/** 错误信息 | Error message */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 环境检测结果
|
||||
* Environment check result
|
||||
*/
|
||||
export interface EnvironmentCheckResult {
|
||||
/** 所有必需工具是否可用 | Whether all required tools are available */
|
||||
ready: boolean;
|
||||
/** esbuild 可用性状态 | esbuild availability status */
|
||||
esbuild: ToolStatus;
|
||||
}
|
||||
|
||||
export interface DirectoryEntry {
|
||||
|
||||
@@ -39,7 +39,8 @@ import {
|
||||
WeChatBuildPipeline,
|
||||
moduleRegistry,
|
||||
UserCodeService,
|
||||
UserCodeTarget
|
||||
UserCodeTarget,
|
||||
type HotReloadEvent
|
||||
} from '@esengine/editor-core';
|
||||
import { ViewportService } from '../../services/ViewportService';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
@@ -146,6 +147,10 @@ export class ServiceRegistry {
|
||||
CoreComponentRegistry.register(comp.type as any);
|
||||
}
|
||||
|
||||
// Enable hot reload for editor environment
|
||||
// 在编辑器环境中启用热更新
|
||||
CoreComponentRegistry.enableHotReload();
|
||||
|
||||
const projectService = new ProjectService(messageHub, fileAPI);
|
||||
const componentDiscovery = new ComponentDiscoveryService(messageHub);
|
||||
const propertyMetadata = new PropertyMetadataService();
|
||||
@@ -321,10 +326,46 @@ export class ServiceRegistry {
|
||||
messageHub.subscribe('project:opened', async (data: { path: string; type: string; name: string }) => {
|
||||
currentProjectPath = data.path;
|
||||
await compileAndLoadUserScripts(data.path);
|
||||
|
||||
// Start watching for file changes (external editor support)
|
||||
// 开始监视文件变更(支持外部编辑器)
|
||||
userCodeService.watch(data.path, async (event) => {
|
||||
console.log('[UserCodeService] Hot reload event:', event.changedFiles);
|
||||
|
||||
if (event.newModule) {
|
||||
// 1. Register new/updated components to registries
|
||||
// 1. 注册新的/更新的组件到注册表
|
||||
userCodeService.registerComponents(event.newModule, componentRegistry);
|
||||
|
||||
// 2. Hot reload: update prototype chain of existing instances
|
||||
// 2. 热更新:更新现有实例的原型链
|
||||
const updatedCount = userCodeService.hotReloadInstances(event.newModule);
|
||||
console.log(`[UserCodeService] Hot reloaded ${updatedCount} component instances`);
|
||||
|
||||
// 3. Notify that user code has been reloaded
|
||||
// 3. 通知用户代码已重新加载
|
||||
messageHub.publish('usercode:reloaded', {
|
||||
projectPath: data.path,
|
||||
exports: Object.keys(event.newModule.exports),
|
||||
updatedInstances: updatedCount
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
console.warn('[UserCodeService] Failed to start file watcher:', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to script file changes (create/delete/modify)
|
||||
// 订阅脚本文件变更(创建/删除/修改)
|
||||
// Subscribe to project:closed to stop watching
|
||||
// 订阅 project:closed 以停止监视
|
||||
messageHub.subscribe('project:closed', async () => {
|
||||
currentProjectPath = null;
|
||||
await userCodeService.stopWatch();
|
||||
});
|
||||
|
||||
// Subscribe to script file changes (create/delete) from editor operations
|
||||
// 订阅编辑器操作的脚本文件变更(创建/删除)
|
||||
// Note: file:modified is handled by the Rust file watcher for external editor support
|
||||
// 注意:file:modified 由 Rust 文件监视器处理以支持外部编辑器
|
||||
messageHub.subscribe('file:created', async (data: { path: string }) => {
|
||||
if (currentProjectPath && this.isScriptFile(data.path)) {
|
||||
await compileAndLoadUserScripts(currentProjectPath);
|
||||
@@ -337,12 +378,6 @@ export class ServiceRegistry {
|
||||
}
|
||||
});
|
||||
|
||||
messageHub.subscribe('file:modified', async (data: { path: string }) => {
|
||||
if (currentProjectPath && this.isScriptFile(data.path)) {
|
||||
await compileAndLoadUserScripts(currentProjectPath);
|
||||
}
|
||||
});
|
||||
|
||||
// 注册默认场景模板 - 创建默认相机
|
||||
// Register default scene template - creates default camera
|
||||
this.registerDefaultSceneTemplate();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { Globe, ChevronDown, Download, X, Loader2, Trash2 } from 'lucide-react';
|
||||
import { Globe, ChevronDown, Download, X, Loader2, Trash2, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { checkForUpdatesOnStartup, installUpdate, type UpdateCheckResult } from '../utils/updater';
|
||||
import { StartupLogo } from './StartupLogo';
|
||||
import { TauriAPI, type EnvironmentCheckResult } from '../api/tauri';
|
||||
import '../styles/StartupPage.css';
|
||||
|
||||
type Locale = 'en' | 'zh';
|
||||
@@ -33,6 +34,8 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null);
|
||||
const [showUpdateBanner, setShowUpdateBanner] = useState(false);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [envCheck, setEnvCheck] = useState<EnvironmentCheckResult | null>(null);
|
||||
const [showEnvStatus, setShowEnvStatus] = useState(false);
|
||||
const langMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,6 +62,24 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 启动时检测开发环境
|
||||
useEffect(() => {
|
||||
TauriAPI.checkEnvironment().then((result) => {
|
||||
setEnvCheck(result);
|
||||
// 如果环境就绪,在控制台显示信息
|
||||
if (result.ready) {
|
||||
console.log('[Environment] Ready ✓');
|
||||
console.log(`[Environment] esbuild: ${result.esbuild.version} (${result.esbuild.source})`);
|
||||
} else {
|
||||
// 环境有问题,显示提示
|
||||
setShowEnvStatus(true);
|
||||
console.warn('[Environment] Not ready:', result.esbuild.error);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('[Environment] Check failed:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
title: 'ESEngine Editor',
|
||||
@@ -76,7 +97,11 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
deleteConfirmTitle: 'Delete Project',
|
||||
deleteConfirmMessage: 'Are you sure you want to permanently delete this project? This action cannot be undone.',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete'
|
||||
delete: 'Delete',
|
||||
envReady: 'Environment Ready',
|
||||
envNotReady: 'Environment Issue',
|
||||
esbuildReady: 'esbuild ready',
|
||||
esbuildMissing: 'esbuild not found'
|
||||
},
|
||||
zh: {
|
||||
title: 'ESEngine 编辑器',
|
||||
@@ -94,7 +119,11 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
deleteConfirmTitle: '删除项目',
|
||||
deleteConfirmMessage: '确定要永久删除此项目吗?此操作无法撤销。',
|
||||
cancel: '取消',
|
||||
delete: '删除'
|
||||
delete: '删除',
|
||||
envReady: '环境就绪',
|
||||
envNotReady: '环境问题',
|
||||
esbuildReady: 'esbuild 就绪',
|
||||
esbuildMissing: '未找到 esbuild'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -220,6 +249,43 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
|
||||
<div className="startup-footer">
|
||||
<span className="startup-version">{versionText}</span>
|
||||
|
||||
{/* 环境状态指示器 | Environment Status Indicator */}
|
||||
{envCheck && (
|
||||
<div
|
||||
className={`startup-env-status ${envCheck.ready ? 'ready' : 'warning'}`}
|
||||
onClick={() => setShowEnvStatus(!showEnvStatus)}
|
||||
title={envCheck.ready ? t.envReady : t.envNotReady}
|
||||
>
|
||||
{envCheck.ready ? (
|
||||
<CheckCircle size={14} />
|
||||
) : (
|
||||
<AlertCircle size={14} />
|
||||
)}
|
||||
{showEnvStatus && (
|
||||
<div className="startup-env-tooltip">
|
||||
<div className="env-tooltip-title">
|
||||
{envCheck.ready ? t.envReady : t.envNotReady}
|
||||
</div>
|
||||
<div className={`env-tooltip-item ${envCheck.esbuild.available ? 'ok' : 'error'}`}>
|
||||
{envCheck.esbuild.available ? (
|
||||
<>
|
||||
<CheckCircle size={12} />
|
||||
<span>esbuild {envCheck.esbuild.version}</span>
|
||||
<span className="env-source">({envCheck.esbuild.source})</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle size={12} />
|
||||
<span>{t.esbuildMissing}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onLocaleChange && (
|
||||
<div className="startup-locale-dropdown" ref={langMenuRef}>
|
||||
<button
|
||||
|
||||
@@ -526,3 +526,92 @@
|
||||
background: #b91c1c;
|
||||
border-color: #b91c1c;
|
||||
}
|
||||
|
||||
/* 环境状态指示器样式 | Environment Status Indicator Styles */
|
||||
.startup-env-status {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.startup-env-status.ready {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.startup-env-status.ready:hover {
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
}
|
||||
|
||||
.startup-env-status.warning {
|
||||
color: #f59e0b;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.startup-env-status.warning:hover {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.startup-env-tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 8px;
|
||||
padding: 12px 16px;
|
||||
min-width: 200px;
|
||||
background: #2d2d30;
|
||||
border: 1px solid #3e3e42;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.startup-env-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: #3e3e42;
|
||||
}
|
||||
|
||||
.env-tooltip-title {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #3e3e42;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.env-tooltip-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.env-tooltip-item.ok {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.env-tooltip-item.error {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.env-source {
|
||||
opacity: 0.6;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -115,7 +115,9 @@ export class UserCodeService implements IService, IUserCodeService {
|
||||
|
||||
const sep = options.projectPath.includes('\\') ? '\\' : '/';
|
||||
const scriptsDir = `${options.projectPath}${sep}${SCRIPTS_DIR}`;
|
||||
const outputDir = options.outputDir || `${options.projectPath}${sep}${USER_CODE_OUTPUT_DIR}`;
|
||||
// Ensure consistent path separators | 确保路径分隔符一致
|
||||
const userCodeOutputDir = USER_CODE_OUTPUT_DIR.replace(/\//g, sep);
|
||||
const outputDir = options.outputDir || `${options.projectPath}${sep}${userCodeOutputDir}`;
|
||||
|
||||
try {
|
||||
// Scan scripts first | 先扫描脚本
|
||||
@@ -379,6 +381,83 @@ export class UserCodeService implements IService, IUserCodeService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot reload: update existing component instances to use new class prototype.
|
||||
* 热更新:更新现有组件实例以使用新类的原型。
|
||||
*
|
||||
* This is the core of hot reload - it updates the prototype chain of existing
|
||||
* instances so they use the new methods from the updated class while preserving
|
||||
* their data (properties).
|
||||
* 这是热更新的核心 - 它更新现有实例的原型链,使它们使用更新后类的新方法,
|
||||
* 同时保留它们的数据(属性)。
|
||||
*
|
||||
* @param module - New user code module | 新的用户代码模块
|
||||
* @returns Number of instances updated | 更新的实例数量
|
||||
*/
|
||||
hotReloadInstances(module: UserCodeModule): number {
|
||||
if (module.target !== UserCodeTarget.Runtime) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Access scene through Core.scene
|
||||
// 通过 Core.scene 访问场景
|
||||
const Core = (window as any).__ESENGINE__?.ecsFramework?.Core;
|
||||
const scene = Core?.scene;
|
||||
if (!scene || !scene.entities) {
|
||||
logger.warn('No active scene for hot reload | 没有活动场景用于热更新');
|
||||
return 0;
|
||||
}
|
||||
|
||||
let updatedCount = 0;
|
||||
|
||||
// EntityList.buffer contains all entities
|
||||
// EntityList.buffer 包含所有实体
|
||||
const entities: any[] = scene.entities.buffer || [];
|
||||
|
||||
for (const entity of entities) {
|
||||
if (!entity) continue;
|
||||
|
||||
// entity.components is a getter that returns readonly Component[]
|
||||
// entity.components 是一个 getter,返回 readonly Component[]
|
||||
const components = entity.components;
|
||||
if (!components || !Array.isArray(components)) continue;
|
||||
|
||||
for (const component of components) {
|
||||
if (!component) continue;
|
||||
|
||||
// Get the component's type name
|
||||
// 获取组件的类型名称
|
||||
const typeName = component.constructor?.name;
|
||||
if (!typeName) continue;
|
||||
|
||||
// Check if we have a new version of this component class
|
||||
// 检查是否有此组件类的新版本
|
||||
const newClass = module.exports[typeName];
|
||||
if (!newClass || typeof newClass !== 'function') continue;
|
||||
|
||||
// Check if this is actually a different class (hot reload scenario)
|
||||
// 检查这是否确实是不同的类(热更新场景)
|
||||
if (component.constructor === newClass) continue;
|
||||
|
||||
// Update the prototype chain to use the new class
|
||||
// 更新原型链以使用新类
|
||||
try {
|
||||
Object.setPrototypeOf(component, newClass.prototype);
|
||||
updatedCount++;
|
||||
logger.debug(`Hot reloaded component instance: ${typeName} on entity ${entity.name || entity.id}`);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to hot reload ${typeName}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedCount > 0) {
|
||||
logger.info(`Hot reload: updated ${updatedCount} component instances | 热更新:更新了 ${updatedCount} 个组件实例`);
|
||||
}
|
||||
|
||||
return updatedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register editor extensions from user module.
|
||||
* 从用户模块注册编辑器扩展。
|
||||
|
||||
Reference in New Issue
Block a user