feat: 添加跨平台运行时、资产系统和UI适配功能 (#256)

* feat(platform-common): 添加WASM加载器和环境检测API

* feat(rapier2d): 新增Rapier2D WASM绑定包

* feat(physics-rapier2d): 添加跨平台WASM加载器

* feat(asset-system): 添加运行时资产目录和bundle格式

* feat(asset-system-editor): 新增编辑器资产管理包

* feat(editor-core): 添加构建系统和模块管理

* feat(editor-app): 重构浏览器预览使用import maps

* feat(platform-web): 添加BrowserRuntime和资产读取

* feat(engine): 添加材质系统和着色器管理

* feat(material): 新增材质系统和着色器编辑器

* feat(tilemap): 增强tilemap编辑器和动画系统

* feat(modules): 添加module.json配置

* feat(core): 添加module.json和类型定义更新

* chore: 更新依赖和构建配置

* refactor(plugins): 更新插件模板使用ModuleManifest

* chore: 添加第三方依赖库

* chore: 移除BehaviourTree-ai和ecs-astar子模块

* docs: 更新README和文档主题样式

* fix: 修复Rust文档测试和添加rapier2d WASM绑定

* fix(tilemap-editor): 修复画布高DPI屏幕分辨率适配问题

* feat(ui): 添加UI屏幕适配系统(CanvasScaler/SafeArea)

* fix(ecs-engine-bindgen): 添加缺失的ecs-framework-math依赖

* fix: 添加缺失的包依赖修复CI构建

* fix: 修复CodeQL检测到的代码问题

* fix: 修复构建错误和缺失依赖

* fix: 修复类型检查错误

* fix(material-system): 修复tsconfig配置支持TypeScript项目引用

* fix(editor-core): 修复Rollup构建配置添加tauri external

* fix: 修复CodeQL检测到的代码问题

* fix: 修复CodeQL检测到的代码问题
This commit is contained in:
YHH
2025-12-03 22:15:22 +08:00
committed by GitHub
parent caf7622aa0
commit 63f006ab62
496 changed files with 77601 additions and 4067 deletions

View 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;

View 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;

View File

@@ -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) {

View 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

View File

@@ -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 },

View 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;

View File

@@ -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">

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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 },

View File

@@ -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`;

View File

@@ -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} />;
}

View File

@@ -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 元素

View 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);
}