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 maskCache = new Map<string, BitMask64Data>();
|
||||||
private static nextBitIndex = 0;
|
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 组件类型
|
* @param componentType 组件类型
|
||||||
@@ -34,11 +42,27 @@ export class ComponentRegistry {
|
|||||||
return existingIndex;
|
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);
|
const existingType = this.componentNameToType.get(typeName);
|
||||||
if (existingType !== componentType) {
|
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();
|
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.componentNameToId.clear();
|
||||||
this.maskCache.clear();
|
this.maskCache.clear();
|
||||||
this.nextBitIndex = 0;
|
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('边界情况', () => {
|
describe('边界情况', () => {
|
||||||
it('应该正确处理第 64 个组件(边界)', () => {
|
it('应该正确处理第 64 个组件(边界)', () => {
|
||||||
const scene = new Scene();
|
const scene = new Scene();
|
||||||
|
|||||||
2
packages/editor-app/.gitignore
vendored
2
packages/editor-app/.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
# Generated runtime files
|
# Generated runtime files
|
||||||
src-tauri/runtime/
|
src-tauri/runtime/
|
||||||
|
src-tauri/engine/
|
||||||
|
src-tauri/bin/
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ if (!fs.existsSync(bundleDir)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Files to bundle
|
// Files to bundle
|
||||||
|
// 需要打包的文件
|
||||||
const filesToBundle = [
|
const filesToBundle = [
|
||||||
{
|
{
|
||||||
src: path.join(rootPath, 'packages/platform-web/dist/runtime.browser.js'),
|
src: path.join(rootPath, 'packages/platform-web/dist/index.mjs'),
|
||||||
dst: path.join(bundleDir, 'runtime.browser.js')
|
dst: path.join(bundleDir, 'platform-web.mjs')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: path.join(rootPath, 'packages/engine/pkg/es_engine_bg.wasm'),
|
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
|
// Copy engine modules directory from dist/engine to src-tauri/engine
|
||||||
if (success) {
|
// 复制引擎模块目录从 dist/engine 到 src-tauri/engine
|
||||||
const tauriConfigPath = path.join(editorPath, 'src-tauri', 'tauri.conf.json');
|
const engineSrcDir = path.join(editorPath, 'dist', 'engine');
|
||||||
const config = JSON.parse(fs.readFileSync(tauriConfigPath, 'utf8'));
|
const engineDstDir = path.join(editorPath, 'src-tauri', 'engine');
|
||||||
|
|
||||||
// Add runtime directory to resources
|
/**
|
||||||
if (!config.bundle) {
|
* Recursively copy directory
|
||||||
config.bundle = {};
|
* 递归复制目录
|
||||||
}
|
*/
|
||||||
if (!config.bundle.resources) {
|
function copyDirRecursive(src, dst) {
|
||||||
config.bundle.resources = {};
|
if (!fs.existsSync(src)) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle both array and object format for resources
|
if (!fs.existsSync(dst)) {
|
||||||
if (Array.isArray(config.bundle.resources)) {
|
fs.mkdirSync(dst, { recursive: true });
|
||||||
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
|
const entries = fs.readdirSync(src, { withFileTypes: true });
|
||||||
if (!config.bundle.resources['runtime/**/*']) {
|
for (const entry of entries) {
|
||||||
config.bundle.resources['runtime/**/*'] = '.';
|
const srcPath = path.join(src, entry.name);
|
||||||
fs.writeFileSync(tauriConfigPath, JSON.stringify(config, null, 2));
|
const dstPath = path.join(dst, entry.name);
|
||||||
console.log('✓ Updated tauri.conf.json with runtime resources');
|
|
||||||
|
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) {
|
if (!success) {
|
||||||
|
|||||||
@@ -67,6 +67,34 @@ pub struct CompileResult {
|
|||||||
pub output_path: Option<String>,
|
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.
|
/// File change event sent to frontend.
|
||||||
/// 发送到前端的文件变更事件。
|
/// 发送到前端的文件变更事件。
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -78,6 +106,82 @@ pub struct FileChangeEvent {
|
|||||||
pub paths: Vec<String>,
|
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.
|
/// Compile TypeScript using esbuild.
|
||||||
/// 使用 esbuild 编译 TypeScript。
|
/// 使用 esbuild 编译 TypeScript。
|
||||||
///
|
///
|
||||||
@@ -254,6 +358,11 @@ pub async fn watch_scripts(
|
|||||||
|
|
||||||
println!("[UserCode] Started watching: {}", watch_path_clone.display());
|
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 | 事件循环
|
// Event loop | 事件循环
|
||||||
loop {
|
loop {
|
||||||
// Check for shutdown | 检查关闭信号
|
// Check for shutdown | 检查关闭信号
|
||||||
@@ -277,19 +386,28 @@ pub async fn watch_scripts(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if !ts_paths.is_empty() {
|
if !ts_paths.is_empty() {
|
||||||
let change_type = match event.kind {
|
// Only handle create/modify/remove events | 只处理创建/修改/删除事件
|
||||||
EventKind::Create(_) => "create",
|
match event.kind {
|
||||||
EventKind::Modify(_) => "modify",
|
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
|
||||||
EventKind::Remove(_) => "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,
|
_ => 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 {
|
let file_event = FileChangeEvent {
|
||||||
change_type: change_type.to_string(),
|
change_type: "modify".to_string(),
|
||||||
paths: ts_paths,
|
paths: pending_paths.drain().collect(),
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("[UserCode] File change detected: {:?}", file_event);
|
println!("[UserCode] File change detected (debounced): {:?}", file_event);
|
||||||
|
|
||||||
// Emit event to frontend | 向前端发送事件
|
// Emit event to frontend | 向前端发送事件
|
||||||
if let Err(e) = app_clone.emit("user-code:file-changed", file_event) {
|
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) => {
|
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||||
println!("[UserCode] Watcher channel disconnected");
|
println!("[UserCode] Watcher channel disconnected");
|
||||||
break;
|
break;
|
||||||
@@ -352,10 +467,21 @@ pub async fn stop_watch_scripts(
|
|||||||
|
|
||||||
/// Find esbuild executable path.
|
/// Find esbuild executable path.
|
||||||
/// 查找 esbuild 可执行文件路径。
|
/// 查找 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> {
|
fn find_esbuild(project_root: &str) -> Result<String, String> {
|
||||||
let project_path = Path::new(project_root);
|
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) {
|
let local_esbuild = if cfg!(windows) {
|
||||||
project_path.join("node_modules/.bin/esbuild.cmd")
|
project_path.join("node_modules/.bin/esbuild.cmd")
|
||||||
} else {
|
} else {
|
||||||
@@ -363,6 +489,7 @@ fn find_esbuild(project_root: &str) -> Result<String, String> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if local_esbuild.exists() {
|
if local_esbuild.exists() {
|
||||||
|
println!("[Compiler] Using local esbuild: {}", local_esbuild.display());
|
||||||
return Ok(local_esbuild.to_string_lossy().to_string());
|
return Ok(local_esbuild.to_string_lossy().to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,11 +502,51 @@ fn find_esbuild(project_root: &str) -> Result<String, String> {
|
|||||||
.output();
|
.output();
|
||||||
|
|
||||||
match check {
|
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())
|
_ => 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.
|
/// Parse esbuild error output.
|
||||||
/// 解析 esbuild 错误输出。
|
/// 解析 esbuild 错误输出。
|
||||||
fn parse_esbuild_errors(stderr: &str) -> Vec<CompileError> {
|
fn parse_esbuild_errors(stderr: &str) -> Vec<CompileError> {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ fn main() {
|
|||||||
commands::compile_typescript,
|
commands::compile_typescript,
|
||||||
commands::watch_scripts,
|
commands::watch_scripts,
|
||||||
commands::stop_watch_scripts,
|
commands::stop_watch_scripts,
|
||||||
|
commands::check_environment,
|
||||||
// Build commands | 构建命令
|
// Build commands | 构建命令
|
||||||
commands::prepare_build_directory,
|
commands::prepare_build_directory,
|
||||||
commands::copy_directory,
|
commands::copy_directory,
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"createUpdaterArtifacts": true,
|
"createUpdaterArtifacts": true,
|
||||||
"resources": {
|
"resources": [
|
||||||
"runtime/**/*": "."
|
"runtime/**/*",
|
||||||
},
|
"engine/**/*",
|
||||||
|
"bin/*"
|
||||||
|
],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
@@ -357,6 +357,47 @@ export class TauriAPI {
|
|||||||
static convertFileSrc(filePath: string, protocol?: string): string {
|
static convertFileSrc(filePath: string, protocol?: string): string {
|
||||||
return convertFileSrc(filePath, protocol);
|
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 {
|
export interface DirectoryEntry {
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ import {
|
|||||||
WeChatBuildPipeline,
|
WeChatBuildPipeline,
|
||||||
moduleRegistry,
|
moduleRegistry,
|
||||||
UserCodeService,
|
UserCodeService,
|
||||||
UserCodeTarget
|
UserCodeTarget,
|
||||||
|
type HotReloadEvent
|
||||||
} from '@esengine/editor-core';
|
} from '@esengine/editor-core';
|
||||||
import { ViewportService } from '../../services/ViewportService';
|
import { ViewportService } from '../../services/ViewportService';
|
||||||
import { TransformComponent } from '@esengine/engine-core';
|
import { TransformComponent } from '@esengine/engine-core';
|
||||||
@@ -146,6 +147,10 @@ export class ServiceRegistry {
|
|||||||
CoreComponentRegistry.register(comp.type as any);
|
CoreComponentRegistry.register(comp.type as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable hot reload for editor environment
|
||||||
|
// 在编辑器环境中启用热更新
|
||||||
|
CoreComponentRegistry.enableHotReload();
|
||||||
|
|
||||||
const projectService = new ProjectService(messageHub, fileAPI);
|
const projectService = new ProjectService(messageHub, fileAPI);
|
||||||
const componentDiscovery = new ComponentDiscoveryService(messageHub);
|
const componentDiscovery = new ComponentDiscoveryService(messageHub);
|
||||||
const propertyMetadata = new PropertyMetadataService();
|
const propertyMetadata = new PropertyMetadataService();
|
||||||
@@ -321,10 +326,46 @@ export class ServiceRegistry {
|
|||||||
messageHub.subscribe('project:opened', async (data: { path: string; type: string; name: string }) => {
|
messageHub.subscribe('project:opened', async (data: { path: string; type: string; name: string }) => {
|
||||||
currentProjectPath = data.path;
|
currentProjectPath = data.path;
|
||||||
await compileAndLoadUserScripts(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 }) => {
|
messageHub.subscribe('file:created', async (data: { path: string }) => {
|
||||||
if (currentProjectPath && this.isScriptFile(data.path)) {
|
if (currentProjectPath && this.isScriptFile(data.path)) {
|
||||||
await compileAndLoadUserScripts(currentProjectPath);
|
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
|
// Register default scene template - creates default camera
|
||||||
this.registerDefaultSceneTemplate();
|
this.registerDefaultSceneTemplate();
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
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 { checkForUpdatesOnStartup, installUpdate, type UpdateCheckResult } from '../utils/updater';
|
||||||
import { StartupLogo } from './StartupLogo';
|
import { StartupLogo } from './StartupLogo';
|
||||||
|
import { TauriAPI, type EnvironmentCheckResult } from '../api/tauri';
|
||||||
import '../styles/StartupPage.css';
|
import '../styles/StartupPage.css';
|
||||||
|
|
||||||
type Locale = 'en' | 'zh';
|
type Locale = 'en' | 'zh';
|
||||||
@@ -33,6 +34,8 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
|||||||
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null);
|
||||||
const [showUpdateBanner, setShowUpdateBanner] = useState(false);
|
const [showUpdateBanner, setShowUpdateBanner] = useState(false);
|
||||||
const [isInstalling, setIsInstalling] = useState(false);
|
const [isInstalling, setIsInstalling] = useState(false);
|
||||||
|
const [envCheck, setEnvCheck] = useState<EnvironmentCheckResult | null>(null);
|
||||||
|
const [showEnvStatus, setShowEnvStatus] = useState(false);
|
||||||
const langMenuRef = useRef<HTMLDivElement>(null);
|
const langMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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 = {
|
const translations = {
|
||||||
en: {
|
en: {
|
||||||
title: 'ESEngine Editor',
|
title: 'ESEngine Editor',
|
||||||
@@ -76,7 +97,11 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
|||||||
deleteConfirmTitle: 'Delete Project',
|
deleteConfirmTitle: 'Delete Project',
|
||||||
deleteConfirmMessage: 'Are you sure you want to permanently delete this project? This action cannot be undone.',
|
deleteConfirmMessage: 'Are you sure you want to permanently delete this project? This action cannot be undone.',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
delete: 'Delete'
|
delete: 'Delete',
|
||||||
|
envReady: 'Environment Ready',
|
||||||
|
envNotReady: 'Environment Issue',
|
||||||
|
esbuildReady: 'esbuild ready',
|
||||||
|
esbuildMissing: 'esbuild not found'
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
title: 'ESEngine 编辑器',
|
title: 'ESEngine 编辑器',
|
||||||
@@ -94,7 +119,11 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
|||||||
deleteConfirmTitle: '删除项目',
|
deleteConfirmTitle: '删除项目',
|
||||||
deleteConfirmMessage: '确定要永久删除此项目吗?此操作无法撤销。',
|
deleteConfirmMessage: '确定要永久删除此项目吗?此操作无法撤销。',
|
||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
delete: '删除'
|
delete: '删除',
|
||||||
|
envReady: '环境就绪',
|
||||||
|
envNotReady: '环境问题',
|
||||||
|
esbuildReady: 'esbuild 就绪',
|
||||||
|
esbuildMissing: '未找到 esbuild'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -220,6 +249,43 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
|||||||
|
|
||||||
<div className="startup-footer">
|
<div className="startup-footer">
|
||||||
<span className="startup-version">{versionText}</span>
|
<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 && (
|
{onLocaleChange && (
|
||||||
<div className="startup-locale-dropdown" ref={langMenuRef}>
|
<div className="startup-locale-dropdown" ref={langMenuRef}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -526,3 +526,92 @@
|
|||||||
background: #b91c1c;
|
background: #b91c1c;
|
||||||
border-color: #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 sep = options.projectPath.includes('\\') ? '\\' : '/';
|
||||||
const scriptsDir = `${options.projectPath}${sep}${SCRIPTS_DIR}`;
|
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 {
|
try {
|
||||||
// Scan scripts first | 先扫描脚本
|
// 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.
|
* Register editor extensions from user module.
|
||||||
* 从用户模块注册编辑器扩展。
|
* 从用户模块注册编辑器扩展。
|
||||||
|
|||||||
Reference in New Issue
Block a user