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:
897
packages/editor-app/src/components/BuildSettingsPanel.tsx
Normal file
897
packages/editor-app/src/components/BuildSettingsPanel.tsx
Normal file
@@ -0,0 +1,897 @@
|
||||
/**
|
||||
* Build Settings Panel.
|
||||
* 构建设置面板。
|
||||
*
|
||||
* Provides build settings interface for managing platform builds,
|
||||
* scenes, and player settings.
|
||||
* 提供构建设置界面,用于管理平台构建、场景和玩家设置。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Monitor, Apple, Smartphone, Globe, Server, Gamepad2,
|
||||
Plus, Minus, ChevronDown, ChevronRight, Settings,
|
||||
Package, Loader2, CheckCircle, XCircle, AlertTriangle, X
|
||||
} from 'lucide-react';
|
||||
import type { BuildService, BuildProgress, BuildConfig, WebBuildConfig, WeChatBuildConfig, SceneManagerService } from '@esengine/editor-core';
|
||||
import { BuildPlatform, BuildStatus } from '@esengine/editor-core';
|
||||
import '../styles/BuildSettingsPanel.css';
|
||||
|
||||
// ==================== Types | 类型定义 ====================
|
||||
|
||||
/** Platform type | 平台类型 */
|
||||
type PlatformType =
|
||||
| 'windows'
|
||||
| 'macos'
|
||||
| 'linux'
|
||||
| 'android'
|
||||
| 'ios'
|
||||
| 'web'
|
||||
| 'wechat-minigame';
|
||||
|
||||
/** Build profile | 构建配置 */
|
||||
interface BuildProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: PlatformType;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/** Scene entry | 场景条目 */
|
||||
interface SceneEntry {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Platform configuration | 平台配置 */
|
||||
interface PlatformConfig {
|
||||
platform: PlatformType;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
/** Build settings | 构建设置 */
|
||||
interface BuildSettings {
|
||||
scenes: SceneEntry[];
|
||||
scriptingDefines: string[];
|
||||
companyName: string;
|
||||
productName: string;
|
||||
version: string;
|
||||
// Platform-specific | 平台特定
|
||||
developmentBuild: boolean;
|
||||
sourceMap: boolean;
|
||||
compressionMethod: 'Default' | 'LZ4' | 'LZ4HC';
|
||||
bundleModules: boolean;
|
||||
}
|
||||
|
||||
// ==================== Constants | 常量 ====================
|
||||
|
||||
const PLATFORMS: PlatformConfig[] = [
|
||||
{ platform: 'windows', label: 'Windows', icon: <Monitor size={16} />, available: true },
|
||||
{ platform: 'macos', label: 'macOS', icon: <Apple size={16} />, available: true },
|
||||
{ platform: 'linux', label: 'Linux', icon: <Server size={16} />, available: true },
|
||||
{ platform: 'android', label: 'Android', icon: <Smartphone size={16} />, available: true },
|
||||
{ platform: 'ios', label: 'iOS', icon: <Smartphone size={16} />, available: true },
|
||||
{ platform: 'web', label: 'Web', icon: <Globe size={16} />, available: true },
|
||||
{ platform: 'wechat-minigame', label: 'WeChat Mini Game', icon: <Gamepad2 size={16} />, available: true },
|
||||
];
|
||||
|
||||
const DEFAULT_SETTINGS: BuildSettings = {
|
||||
scenes: [],
|
||||
scriptingDefines: [],
|
||||
companyName: 'DefaultCompany',
|
||||
productName: 'MyGame',
|
||||
version: '0.1.0',
|
||||
developmentBuild: false,
|
||||
sourceMap: false,
|
||||
compressionMethod: 'Default',
|
||||
bundleModules: false,
|
||||
};
|
||||
|
||||
// ==================== i18n | 国际化 ====================
|
||||
|
||||
const i18n = {
|
||||
en: {
|
||||
buildProfiles: 'Build Profiles',
|
||||
addBuildProfile: 'Add Build Profile',
|
||||
playerSettings: 'Player Settings',
|
||||
assetImportOverrides: 'Asset Import Overrides',
|
||||
platforms: 'Platforms',
|
||||
sceneList: 'Scene List',
|
||||
active: 'Active',
|
||||
switchProfile: 'Switch Profile',
|
||||
build: 'Build',
|
||||
buildAndRun: 'Build And Run',
|
||||
buildData: 'Build Data',
|
||||
scriptingDefines: 'Scripting Defines',
|
||||
listIsEmpty: 'List is empty',
|
||||
addOpenScenes: 'Add Open Scenes',
|
||||
platformSettings: 'Platform Settings',
|
||||
architecture: 'Architecture',
|
||||
developmentBuild: 'Development Build',
|
||||
sourceMap: 'Source Map',
|
||||
compressionMethod: 'Compression Method',
|
||||
bundleModules: 'Bundle Modules',
|
||||
bundleModulesHint: 'Merge all modules into single file',
|
||||
separateModulesHint: 'Keep modules as separate files',
|
||||
playerSettingsOverrides: 'Player Settings Overrides',
|
||||
companyName: 'Company Name',
|
||||
productName: 'Product Name',
|
||||
version: 'Version',
|
||||
defaultIcon: 'Default Icon',
|
||||
none: 'None',
|
||||
// Build progress | 构建进度
|
||||
buildInProgress: 'Build in Progress',
|
||||
preparing: 'Preparing...',
|
||||
compiling: 'Compiling...',
|
||||
packaging: 'Packaging assets...',
|
||||
copying: 'Copying files...',
|
||||
postProcessing: 'Post-processing...',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
buildSucceeded: 'Build succeeded!',
|
||||
buildFailed: 'Build failed',
|
||||
warnings: 'Warnings',
|
||||
outputPath: 'Output Path',
|
||||
duration: 'Duration',
|
||||
},
|
||||
zh: {
|
||||
buildProfiles: '构建配置',
|
||||
addBuildProfile: '添加构建配置',
|
||||
playerSettings: '玩家设置',
|
||||
assetImportOverrides: '资源导入覆盖',
|
||||
platforms: '平台',
|
||||
sceneList: '场景列表',
|
||||
active: '激活',
|
||||
switchProfile: '切换配置',
|
||||
build: '构建',
|
||||
buildAndRun: '构建并运行',
|
||||
buildData: '构建数据',
|
||||
scriptingDefines: '脚本定义',
|
||||
listIsEmpty: '列表为空',
|
||||
addOpenScenes: '添加已打开的场景',
|
||||
platformSettings: '平台设置',
|
||||
architecture: '架构',
|
||||
developmentBuild: '开发版本',
|
||||
sourceMap: 'Source Map',
|
||||
compressionMethod: '压缩方式',
|
||||
bundleModules: '打包模块',
|
||||
bundleModulesHint: '合并所有模块为单文件',
|
||||
separateModulesHint: '保持模块为独立文件',
|
||||
playerSettingsOverrides: '玩家设置覆盖',
|
||||
companyName: '公司名称',
|
||||
productName: '产品名称',
|
||||
version: '版本',
|
||||
defaultIcon: '默认图标',
|
||||
none: '无',
|
||||
// Build progress | 构建进度
|
||||
buildInProgress: '正在构建',
|
||||
preparing: '准备中...',
|
||||
compiling: '编译中...',
|
||||
packaging: '打包资源...',
|
||||
copying: '复制文件...',
|
||||
postProcessing: '后处理...',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
buildSucceeded: '构建成功!',
|
||||
buildFailed: '构建失败',
|
||||
warnings: '警告',
|
||||
outputPath: '输出路径',
|
||||
duration: '耗时',
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== Props | 属性 ====================
|
||||
|
||||
interface BuildSettingsPanelProps {
|
||||
projectPath?: string;
|
||||
locale?: string;
|
||||
buildService?: BuildService;
|
||||
sceneManager?: SceneManagerService;
|
||||
onBuild?: (profile: BuildProfile, settings: BuildSettings) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// ==================== Component | 组件 ====================
|
||||
|
||||
export function BuildSettingsPanel({
|
||||
projectPath,
|
||||
locale = 'en',
|
||||
buildService,
|
||||
sceneManager,
|
||||
onBuild,
|
||||
onClose
|
||||
}: BuildSettingsPanelProps) {
|
||||
const t = i18n[locale as keyof typeof i18n] || i18n.en;
|
||||
|
||||
// State | 状态
|
||||
const [profiles, setProfiles] = useState<BuildProfile[]>([
|
||||
{ id: 'web-dev', name: 'Web - Development', platform: 'web', isActive: true },
|
||||
{ id: 'web-prod', name: 'Web - Production', platform: 'web' },
|
||||
{ id: 'wechat', name: 'WeChat Mini Game', platform: 'wechat-minigame' },
|
||||
]);
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<PlatformType>('web');
|
||||
const [selectedProfile, setSelectedProfile] = useState<BuildProfile | null>(profiles[0] || null);
|
||||
const [settings, setSettings] = useState<BuildSettings>(DEFAULT_SETTINGS);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
sceneList: true,
|
||||
scriptingDefines: true,
|
||||
platformSettings: true,
|
||||
playerSettings: true,
|
||||
});
|
||||
|
||||
// Build state | 构建状态
|
||||
const [isBuilding, setIsBuilding] = useState(false);
|
||||
const [buildProgress, setBuildProgress] = useState<BuildProgress | null>(null);
|
||||
const [buildResult, setBuildResult] = useState<{
|
||||
success: boolean;
|
||||
outputPath: string;
|
||||
duration: number;
|
||||
warnings: string[];
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
const [showBuildProgress, setShowBuildProgress] = useState(false);
|
||||
const buildAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Handlers | 处理函数
|
||||
const toggleSection = useCallback((section: string) => {
|
||||
setExpandedSections(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handlePlatformSelect = useCallback((platform: PlatformType) => {
|
||||
setSelectedPlatform(platform);
|
||||
// Find first profile for this platform | 查找此平台的第一个配置
|
||||
const profile = profiles.find(p => p.platform === platform);
|
||||
setSelectedProfile(profile || null);
|
||||
}, [profiles]);
|
||||
|
||||
const handleProfileSelect = useCallback((profile: BuildProfile) => {
|
||||
setSelectedProfile(profile);
|
||||
setSelectedPlatform(profile.platform);
|
||||
}, []);
|
||||
|
||||
const handleAddProfile = useCallback(() => {
|
||||
const newProfile: BuildProfile = {
|
||||
id: `profile-${Date.now()}`,
|
||||
name: `${selectedPlatform} - New Profile`,
|
||||
platform: selectedPlatform,
|
||||
};
|
||||
setProfiles(prev => [...prev, newProfile]);
|
||||
setSelectedProfile(newProfile);
|
||||
}, [selectedPlatform]);
|
||||
|
||||
// Map platform type to BuildPlatform enum | 将平台类型映射到 BuildPlatform 枚举
|
||||
const getPlatformEnum = useCallback((platformType: PlatformType): BuildPlatform => {
|
||||
const platformMap: Record<PlatformType, BuildPlatform> = {
|
||||
'web': BuildPlatform.Web,
|
||||
'wechat-minigame': BuildPlatform.WeChatMiniGame,
|
||||
'windows': BuildPlatform.Desktop,
|
||||
'macos': BuildPlatform.Desktop,
|
||||
'linux': BuildPlatform.Desktop,
|
||||
'android': BuildPlatform.Android,
|
||||
'ios': BuildPlatform.iOS
|
||||
};
|
||||
return platformMap[platformType];
|
||||
}, []);
|
||||
|
||||
const handleBuild = useCallback(async () => {
|
||||
if (!selectedProfile || !projectPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Call external handler if provided | 如果提供了外部处理程序则调用
|
||||
if (onBuild) {
|
||||
onBuild(selectedProfile, settings);
|
||||
}
|
||||
|
||||
// Use BuildService if available | 如果可用则使用 BuildService
|
||||
if (buildService) {
|
||||
setIsBuilding(true);
|
||||
setBuildProgress(null);
|
||||
setBuildResult(null);
|
||||
setShowBuildProgress(true);
|
||||
|
||||
try {
|
||||
const platform = getPlatformEnum(selectedProfile.platform);
|
||||
const baseConfig = {
|
||||
platform,
|
||||
outputPath: `${projectPath}/build/${selectedProfile.platform}`,
|
||||
isRelease: !settings.developmentBuild,
|
||||
sourceMap: settings.sourceMap,
|
||||
scenes: settings.scenes.filter(s => s.enabled).map(s => s.path)
|
||||
};
|
||||
|
||||
// Build platform-specific config | 构建平台特定配置
|
||||
let buildConfig: BuildConfig;
|
||||
if (platform === BuildPlatform.Web) {
|
||||
const webConfig: WebBuildConfig = {
|
||||
...baseConfig,
|
||||
platform: BuildPlatform.Web,
|
||||
format: 'iife',
|
||||
bundleModules: settings.bundleModules,
|
||||
generateHtml: true
|
||||
};
|
||||
buildConfig = webConfig;
|
||||
} else if (platform === BuildPlatform.WeChatMiniGame) {
|
||||
const wechatConfig: WeChatBuildConfig = {
|
||||
...baseConfig,
|
||||
platform: BuildPlatform.WeChatMiniGame,
|
||||
appId: '',
|
||||
useSubpackages: false,
|
||||
mainPackageLimit: 4096,
|
||||
usePlugins: false
|
||||
};
|
||||
buildConfig = wechatConfig;
|
||||
} else {
|
||||
buildConfig = baseConfig;
|
||||
}
|
||||
|
||||
// Execute build with progress callback | 执行构建并传入进度回调
|
||||
const result = await buildService.build(buildConfig, (progress) => {
|
||||
setBuildProgress(progress);
|
||||
});
|
||||
|
||||
// Set result | 设置结果
|
||||
setBuildResult({
|
||||
success: result.success,
|
||||
outputPath: result.outputPath,
|
||||
duration: result.duration,
|
||||
warnings: result.warnings,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Build failed:', error);
|
||||
setBuildResult({
|
||||
success: false,
|
||||
outputPath: '',
|
||||
duration: 0,
|
||||
warnings: [],
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
} finally {
|
||||
setIsBuilding(false);
|
||||
}
|
||||
}
|
||||
}, [selectedProfile, settings, projectPath, buildService, onBuild, getPlatformEnum]);
|
||||
|
||||
// Monitor build progress from service | 从服务监控构建进度
|
||||
useEffect(() => {
|
||||
if (!buildService || !isBuilding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const task = buildService.getCurrentTask();
|
||||
if (task) {
|
||||
setBuildProgress(task.progress);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [buildService, isBuilding]);
|
||||
|
||||
const handleCancelBuild = useCallback(() => {
|
||||
if (buildService) {
|
||||
buildService.cancelBuild();
|
||||
}
|
||||
}, [buildService]);
|
||||
|
||||
const handleCloseBuildProgress = useCallback(() => {
|
||||
if (!isBuilding) {
|
||||
setShowBuildProgress(false);
|
||||
setBuildProgress(null);
|
||||
setBuildResult(null);
|
||||
}
|
||||
}, [isBuilding]);
|
||||
|
||||
// Get status message | 获取状态消息
|
||||
const getStatusMessage = useCallback((status: BuildStatus): string => {
|
||||
const statusMessages: Record<BuildStatus, keyof typeof i18n.en> = {
|
||||
[BuildStatus.Idle]: 'preparing',
|
||||
[BuildStatus.Preparing]: 'preparing',
|
||||
[BuildStatus.Compiling]: 'compiling',
|
||||
[BuildStatus.Packaging]: 'packaging',
|
||||
[BuildStatus.Copying]: 'copying',
|
||||
[BuildStatus.PostProcessing]: 'postProcessing',
|
||||
[BuildStatus.Completed]: 'completed',
|
||||
[BuildStatus.Failed]: 'failed',
|
||||
[BuildStatus.Cancelled]: 'cancelled'
|
||||
};
|
||||
return t[statusMessages[status]] || status;
|
||||
}, [t]);
|
||||
|
||||
const handleAddScene = useCallback(() => {
|
||||
if (!sceneManager) {
|
||||
console.warn('SceneManagerService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const sceneState = sceneManager.getSceneState();
|
||||
const currentScenePath = sceneState.currentScenePath;
|
||||
|
||||
if (!currentScenePath) {
|
||||
console.warn('No scene is currently open');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if scene is already in the list | 检查场景是否已在列表中
|
||||
const exists = settings.scenes.some(s => s.path === currentScenePath);
|
||||
if (exists) {
|
||||
console.log('Scene already in list:', currentScenePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add current scene to the list | 将当前场景添加到列表中
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
scenes: [...prev.scenes, { path: currentScenePath, enabled: true }]
|
||||
}));
|
||||
}, [sceneManager, settings.scenes]);
|
||||
|
||||
const handleAddDefine = useCallback(() => {
|
||||
const define = prompt('Enter scripting define:');
|
||||
if (define) {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
scriptingDefines: [...prev.scriptingDefines, define]
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveDefine = useCallback((index: number) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
scriptingDefines: prev.scriptingDefines.filter((_, i) => i !== index)
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Get platform config | 获取平台配置
|
||||
const currentPlatformConfig = PLATFORMS.find(p => p.platform === selectedPlatform);
|
||||
|
||||
return (
|
||||
<div className="build-settings-panel">
|
||||
{/* Header Tabs | 头部标签 */}
|
||||
<div className="build-settings-header">
|
||||
<div className="build-settings-tabs">
|
||||
<div className="build-settings-tab active">
|
||||
<Package size={14} />
|
||||
{t.buildProfiles}
|
||||
</div>
|
||||
</div>
|
||||
<div className="build-settings-header-actions">
|
||||
<button className="build-settings-header-btn">{t.playerSettings}</button>
|
||||
<button className="build-settings-header-btn">{t.assetImportOverrides}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Profile Bar | 添加配置栏 */}
|
||||
<div className="build-settings-add-bar">
|
||||
<button className="build-settings-add-btn" onClick={handleAddProfile}>
|
||||
<Plus size={14} />
|
||||
{t.addBuildProfile}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Content | 主要内容 */}
|
||||
<div className="build-settings-content">
|
||||
{/* Left Sidebar | 左侧边栏 */}
|
||||
<div className="build-settings-sidebar">
|
||||
{/* Platforms Section | 平台部分 */}
|
||||
<div className="build-settings-section">
|
||||
<div className="build-settings-section-header">{t.platforms}</div>
|
||||
<div className="build-settings-platform-list">
|
||||
{PLATFORMS.map(platform => {
|
||||
const isActive = profiles.some(p => p.platform === platform.platform && p.isActive);
|
||||
return (
|
||||
<div
|
||||
key={platform.platform}
|
||||
className={`build-settings-platform-item ${selectedPlatform === platform.platform ? 'selected' : ''}`}
|
||||
onClick={() => handlePlatformSelect(platform.platform)}
|
||||
>
|
||||
<span className="build-settings-platform-icon">{platform.icon}</span>
|
||||
<span className="build-settings-platform-label">{platform.label}</span>
|
||||
{isActive && <span className="build-settings-active-badge">{t.active}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Profiles Section | 构建配置部分 */}
|
||||
<div className="build-settings-section">
|
||||
<div className="build-settings-section-header">{t.buildProfiles}</div>
|
||||
<div className="build-settings-profile-list">
|
||||
{profiles
|
||||
.filter(p => p.platform === selectedPlatform)
|
||||
.map(profile => (
|
||||
<div
|
||||
key={profile.id}
|
||||
className={`build-settings-profile-item ${selectedProfile?.id === profile.id ? 'selected' : ''}`}
|
||||
onClick={() => handleProfileSelect(profile)}
|
||||
>
|
||||
<span className="build-settings-profile-icon">
|
||||
{currentPlatformConfig?.icon}
|
||||
</span>
|
||||
<span className="build-settings-profile-name">{profile.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel | 右侧面板 */}
|
||||
<div className="build-settings-details">
|
||||
{selectedProfile ? (
|
||||
<>
|
||||
{/* Profile Header | 配置头部 */}
|
||||
<div className="build-settings-details-header">
|
||||
<div className="build-settings-details-title">
|
||||
<span className="build-settings-details-icon">
|
||||
{currentPlatformConfig?.icon}
|
||||
</span>
|
||||
<div className="build-settings-details-info">
|
||||
<h3>{selectedProfile.name}</h3>
|
||||
<span>{currentPlatformConfig?.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="build-settings-details-actions">
|
||||
<button className="build-settings-btn secondary">{t.switchProfile}</button>
|
||||
<button className="build-settings-btn primary" onClick={handleBuild}>
|
||||
{t.build}
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Data Section | 构建数据部分 */}
|
||||
<div className="build-settings-card">
|
||||
<div className="build-settings-card-header">{t.buildData}</div>
|
||||
|
||||
{/* Scene List | 场景列表 */}
|
||||
<div className="build-settings-field-group">
|
||||
<div
|
||||
className="build-settings-field-header"
|
||||
onClick={() => toggleSection('sceneList')}
|
||||
>
|
||||
{expandedSections.sceneList ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{t.sceneList}</span>
|
||||
</div>
|
||||
{expandedSections.sceneList && (
|
||||
<div className="build-settings-field-content">
|
||||
<div className="build-settings-scene-list">
|
||||
{settings.scenes.length === 0 ? (
|
||||
<div className="build-settings-empty-list"></div>
|
||||
) : (
|
||||
settings.scenes.map((scene, index) => (
|
||||
<div key={index} className="build-settings-scene-item">
|
||||
<input type="checkbox" checked={scene.enabled} readOnly />
|
||||
<span>{scene.path}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="build-settings-field-actions">
|
||||
<button className="build-settings-btn text" onClick={handleAddScene}>
|
||||
{t.addOpenScenes}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scripting Defines | 脚本定义 */}
|
||||
<div className="build-settings-field-group">
|
||||
<div
|
||||
className="build-settings-field-header"
|
||||
onClick={() => toggleSection('scriptingDefines')}
|
||||
>
|
||||
{expandedSections.scriptingDefines ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{t.scriptingDefines}</span>
|
||||
</div>
|
||||
{expandedSections.scriptingDefines && (
|
||||
<div className="build-settings-field-content">
|
||||
<div className="build-settings-defines-list">
|
||||
{settings.scriptingDefines.length === 0 ? (
|
||||
<div className="build-settings-empty-text">{t.listIsEmpty}</div>
|
||||
) : (
|
||||
settings.scriptingDefines.map((define, index) => (
|
||||
<div key={index} className="build-settings-define-item">
|
||||
<span>{define}</span>
|
||||
<button onClick={() => handleRemoveDefine(index)}>
|
||||
<Minus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="build-settings-list-actions">
|
||||
<button onClick={handleAddDefine}><Plus size={14} /></button>
|
||||
<button disabled={settings.scriptingDefines.length === 0}>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform Settings Section | 平台设置部分 */}
|
||||
<div className="build-settings-card">
|
||||
<div className="build-settings-card-header">{t.platformSettings}</div>
|
||||
|
||||
<div className="build-settings-field-group">
|
||||
<div
|
||||
className="build-settings-field-header"
|
||||
onClick={() => toggleSection('platformSettings')}
|
||||
>
|
||||
{expandedSections.platformSettings ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{currentPlatformConfig?.label} Settings</span>
|
||||
</div>
|
||||
{expandedSections.platformSettings && (
|
||||
<div className="build-settings-field-content">
|
||||
<div className="build-settings-form">
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.developmentBuild}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.developmentBuild}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
developmentBuild: e.target.checked
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.sourceMap}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.sourceMap}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
sourceMap: e.target.checked
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.compressionMethod}</label>
|
||||
<select
|
||||
value={settings.compressionMethod}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
compressionMethod: e.target.value as any
|
||||
}))}
|
||||
>
|
||||
<option value="Default">Default</option>
|
||||
<option value="LZ4">LZ4</option>
|
||||
<option value="LZ4HC">LZ4HC</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.bundleModules}</label>
|
||||
<div className="build-settings-toggle-group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.bundleModules}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
bundleModules: e.target.checked
|
||||
}))}
|
||||
/>
|
||||
<span className="build-settings-hint">
|
||||
{settings.bundleModules ? t.bundleModulesHint : t.separateModulesHint}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Player Settings Overrides | 玩家设置覆盖 */}
|
||||
<div className="build-settings-card">
|
||||
<div className="build-settings-card-header">
|
||||
{t.playerSettingsOverrides}
|
||||
<button className="build-settings-more-btn">
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="build-settings-field-group">
|
||||
<div
|
||||
className="build-settings-field-header"
|
||||
onClick={() => toggleSection('playerSettings')}
|
||||
>
|
||||
{expandedSections.playerSettings ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>Player Settings</span>
|
||||
</div>
|
||||
{expandedSections.playerSettings && (
|
||||
<div className="build-settings-field-content">
|
||||
<div className="build-settings-form">
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.companyName}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.companyName}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
companyName: e.target.value
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.productName}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.productName}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
productName: e.target.value
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.version}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.version}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
version: e.target.value
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t.defaultIcon}</label>
|
||||
<div className="build-settings-icon-picker">
|
||||
<span>{t.none}</span>
|
||||
<span className="build-settings-icon-hint">(Texture 2D)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="build-settings-no-selection">
|
||||
<p>Select a platform or build profile</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Progress Dialog | 构建进度对话框 */}
|
||||
{showBuildProgress && (
|
||||
<div className="build-progress-overlay">
|
||||
<div className="build-progress-dialog">
|
||||
<div className="build-progress-header">
|
||||
<h3>{t.buildInProgress}</h3>
|
||||
{!isBuilding && (
|
||||
<button
|
||||
className="build-progress-close"
|
||||
onClick={handleCloseBuildProgress}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="build-progress-content">
|
||||
{/* Status Icon | 状态图标 */}
|
||||
<div className="build-progress-status-icon">
|
||||
{isBuilding ? (
|
||||
<Loader2 size={48} className="build-progress-spinner" />
|
||||
) : buildResult?.success ? (
|
||||
<CheckCircle size={48} className="build-progress-success" />
|
||||
) : (
|
||||
<XCircle size={48} className="build-progress-error" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Message | 状态消息 */}
|
||||
<div className="build-progress-message">
|
||||
{isBuilding ? (
|
||||
buildProgress?.message || getStatusMessage(buildProgress?.status || BuildStatus.Preparing)
|
||||
) : buildResult?.success ? (
|
||||
t.buildSucceeded
|
||||
) : (
|
||||
t.buildFailed
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar | 进度条 */}
|
||||
{isBuilding && buildProgress && (
|
||||
<div className="build-progress-bar-container">
|
||||
<div
|
||||
className="build-progress-bar"
|
||||
style={{ width: `${buildProgress.progress}%` }}
|
||||
/>
|
||||
<span className="build-progress-percent">
|
||||
{Math.round(buildProgress.progress)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Build Result Details | 构建结果详情 */}
|
||||
{!isBuilding && buildResult && (
|
||||
<div className="build-result-details">
|
||||
{buildResult.success && (
|
||||
<>
|
||||
<div className="build-result-row">
|
||||
<span className="build-result-label">{t.outputPath}:</span>
|
||||
<span className="build-result-value">{buildResult.outputPath}</span>
|
||||
</div>
|
||||
<div className="build-result-row">
|
||||
<span className="build-result-label">{t.duration}:</span>
|
||||
<span className="build-result-value">
|
||||
{(buildResult.duration / 1000).toFixed(2)}s
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error Message | 错误消息 */}
|
||||
{buildResult.error && (
|
||||
<div className="build-result-error">
|
||||
<AlertTriangle size={16} />
|
||||
<span>{buildResult.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings | 警告 */}
|
||||
{buildResult.warnings.length > 0 && (
|
||||
<div className="build-result-warnings">
|
||||
<div className="build-result-warnings-header">
|
||||
<AlertTriangle size={14} />
|
||||
<span>{t.warnings} ({buildResult.warnings.length})</span>
|
||||
</div>
|
||||
<ul className="build-result-warnings-list">
|
||||
{buildResult.warnings.map((warning, index) => (
|
||||
<li key={index}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions | 操作按钮 */}
|
||||
<div className="build-progress-actions">
|
||||
{isBuilding ? (
|
||||
<button
|
||||
className="build-settings-btn secondary"
|
||||
onClick={handleCancelBuild}
|
||||
>
|
||||
{t.cancel}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="build-settings-btn primary"
|
||||
onClick={handleCloseBuildProgress}
|
||||
>
|
||||
{t.close}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BuildSettingsPanel;
|
||||
62
packages/editor-app/src/components/BuildSettingsWindow.tsx
Normal file
62
packages/editor-app/src/components/BuildSettingsWindow.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Build Settings Window.
|
||||
* 构建设置窗口。
|
||||
*
|
||||
* A modal window that displays the build settings panel.
|
||||
* 显示构建设置面板的模态窗口。
|
||||
*/
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import type { BuildService, SceneManagerService } from '@esengine/editor-core';
|
||||
import { BuildSettingsPanel } from './BuildSettingsPanel';
|
||||
import '../styles/BuildSettingsWindow.css';
|
||||
|
||||
interface BuildSettingsWindowProps {
|
||||
projectPath?: string;
|
||||
locale?: string;
|
||||
buildService?: BuildService;
|
||||
sceneManager?: SceneManagerService;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function BuildSettingsWindow({
|
||||
projectPath,
|
||||
locale = 'en',
|
||||
buildService,
|
||||
sceneManager,
|
||||
onClose
|
||||
}: BuildSettingsWindowProps) {
|
||||
const t = locale === 'zh' ? {
|
||||
title: '构建设置'
|
||||
} : {
|
||||
title: 'Build Settings'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="build-settings-window-overlay">
|
||||
<div className="build-settings-window">
|
||||
<div className="build-settings-window-header">
|
||||
<h2>{t.title}</h2>
|
||||
<button
|
||||
className="build-settings-window-close"
|
||||
onClick={onClose}
|
||||
title="Close"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="build-settings-window-content">
|
||||
<BuildSettingsPanel
|
||||
projectPath={projectPath}
|
||||
locale={locale}
|
||||
buildService={buildService}
|
||||
sceneManager={sceneManager}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BuildSettingsWindow;
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import {
|
||||
Plus,
|
||||
Download,
|
||||
@@ -68,6 +69,21 @@ interface ContentBrowserProps {
|
||||
revealPath?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据图标名获取 Lucide 图标组件
|
||||
*/
|
||||
function getIconComponent(iconName: string | undefined, size: number = 16): React.ReactNode {
|
||||
if (!iconName) return <File size={size} />;
|
||||
|
||||
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
|
||||
const IconComponent = icons[iconName];
|
||||
if (IconComponent) {
|
||||
return <IconComponent size={size} />;
|
||||
}
|
||||
|
||||
return <File size={size} />;
|
||||
}
|
||||
|
||||
// 获取资产类型显示名称
|
||||
function getAssetTypeName(asset: AssetItem): string {
|
||||
if (asset.type === 'folder') return 'Folder';
|
||||
@@ -156,7 +172,8 @@ export function ContentBrowser({
|
||||
dockInLayout: 'Dock in Layout',
|
||||
noProject: 'No project loaded',
|
||||
empty: 'This folder is empty',
|
||||
newFolder: 'New Folder'
|
||||
newFolder: 'New Folder',
|
||||
newPrefix: 'New'
|
||||
},
|
||||
zh: {
|
||||
favorites: '收藏夹',
|
||||
@@ -169,7 +186,8 @@ export function ContentBrowser({
|
||||
dockInLayout: '停靠到布局',
|
||||
noProject: '未加载项目',
|
||||
empty: '文件夹为空',
|
||||
newFolder: '新建文件夹'
|
||||
newFolder: '新建文件夹',
|
||||
newPrefix: '新建'
|
||||
}
|
||||
}[locale] || {
|
||||
favorites: 'Favorites',
|
||||
@@ -182,7 +200,24 @@ export function ContentBrowser({
|
||||
dockInLayout: 'Dock in Layout',
|
||||
noProject: 'No project loaded',
|
||||
empty: 'This folder is empty',
|
||||
newFolder: 'New Folder'
|
||||
newFolder: 'New Folder',
|
||||
newPrefix: 'New'
|
||||
};
|
||||
|
||||
// 文件创建模板的 label 本地化映射
|
||||
const templateLabels: Record<string, { en: string; zh: string }> = {
|
||||
'Material': { en: 'Material', zh: '材质' },
|
||||
'Shader': { en: 'Shader', zh: '着色器' },
|
||||
'Tilemap': { en: 'Tilemap', zh: '瓦片地图' },
|
||||
'Tileset': { en: 'Tileset', zh: '瓦片集' },
|
||||
};
|
||||
|
||||
const getTemplateLabel = (label: string): string => {
|
||||
const mapping = templateLabels[label];
|
||||
if (mapping) {
|
||||
return locale === 'zh' ? mapping.zh : mapping.en;
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
// Build folder tree - use ref to avoid dependency cycle
|
||||
@@ -546,8 +581,10 @@ export function ContentBrowser({
|
||||
if (templates.length > 0) {
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
for (const template of templates) {
|
||||
const localizedLabel = getTemplateLabel(template.label);
|
||||
items.push({
|
||||
label: `New ${template.label}`,
|
||||
label: `${t.newPrefix} ${localizedLabel}`,
|
||||
icon: getIconComponent(template.icon, 16),
|
||||
onClick: () => {
|
||||
setContextMenu(null);
|
||||
if (currentPath) {
|
||||
|
||||
390
packages/editor-app/src/components/EditorViewport.tsx
Normal file
390
packages/editor-app/src/components/EditorViewport.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* EditorViewport Component
|
||||
* 编辑器视口组件
|
||||
*
|
||||
* A reusable viewport component for editor panels that need engine rendering.
|
||||
* Supports camera controls, overlays, and preview scenes.
|
||||
*
|
||||
* 用于需要引擎渲染的编辑器面板的可重用视口组件。
|
||||
* 支持相机控制、覆盖层和预览场景。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import type { ViewportCameraConfig, IViewportOverlay } from '@esengine/editor-core';
|
||||
import { ViewportService } from '../services/ViewportService';
|
||||
import '../styles/EditorViewport.css';
|
||||
|
||||
/**
|
||||
* EditorViewport configuration
|
||||
* 编辑器视口配置
|
||||
*/
|
||||
export interface EditorViewportConfig {
|
||||
/** Unique viewport identifier | 唯一视口标识符 */
|
||||
viewportId: string;
|
||||
/** Initial camera config | 初始相机配置 */
|
||||
initialCamera?: ViewportCameraConfig;
|
||||
/** Whether to show grid | 是否显示网格 */
|
||||
showGrid?: boolean;
|
||||
/** Whether to show gizmos | 是否显示辅助线 */
|
||||
showGizmos?: boolean;
|
||||
/** Background clear color | 背景清除颜色 */
|
||||
clearColor?: { r: number; g: number; b: number; a: number };
|
||||
/** Min zoom level | 最小缩放级别 */
|
||||
minZoom?: number;
|
||||
/** Max zoom level | 最大缩放级别 */
|
||||
maxZoom?: number;
|
||||
/** Enable camera pan | 启用相机平移 */
|
||||
enablePan?: boolean;
|
||||
/** Enable camera zoom | 启用相机缩放 */
|
||||
enableZoom?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* EditorViewport props
|
||||
* 编辑器视口属性
|
||||
*/
|
||||
export interface EditorViewportProps extends EditorViewportConfig {
|
||||
/** Class name for styling | 样式类名 */
|
||||
className?: string;
|
||||
/** Called when camera changes | 相机变化时的回调 */
|
||||
onCameraChange?: (camera: ViewportCameraConfig) => void;
|
||||
/** Called when viewport is ready | 视口准备就绪时的回调 */
|
||||
onReady?: () => void;
|
||||
/** Called on mouse down | 鼠标按下时的回调 */
|
||||
onMouseDown?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
|
||||
/** Called on mouse move | 鼠标移动时的回调 */
|
||||
onMouseMove?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
|
||||
/** Called on mouse up | 鼠标抬起时的回调 */
|
||||
onMouseUp?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
|
||||
/** Called on mouse wheel | 鼠标滚轮时的回调 */
|
||||
onWheel?: (e: React.WheelEvent, worldPos: { x: number; y: number }) => void;
|
||||
/** Render custom overlays | 渲染自定义覆盖层 */
|
||||
renderOverlays?: () => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* EditorViewport handle for imperative access
|
||||
* 编辑器视口句柄,用于命令式访问
|
||||
*/
|
||||
export interface EditorViewportHandle {
|
||||
/** Get current camera | 获取当前相机 */
|
||||
getCamera(): ViewportCameraConfig;
|
||||
/** Set camera | 设置相机 */
|
||||
setCamera(camera: ViewportCameraConfig): void;
|
||||
/** Reset camera to initial state | 重置相机到初始状态 */
|
||||
resetCamera(): void;
|
||||
/** Convert screen coordinates to world coordinates | 将屏幕坐标转换为世界坐标 */
|
||||
screenToWorld(screenX: number, screenY: number): { x: number; y: number };
|
||||
/** Convert world coordinates to screen coordinates | 将世界坐标转换为屏幕坐标 */
|
||||
worldToScreen(worldX: number, worldY: number): { x: number; y: number };
|
||||
/** Get canvas element | 获取画布元素 */
|
||||
getCanvas(): HTMLCanvasElement | null;
|
||||
/** Request render | 请求渲染 */
|
||||
requestRender(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* EditorViewport Component
|
||||
* 编辑器视口组件
|
||||
*/
|
||||
export const EditorViewport = forwardRef<EditorViewportHandle, EditorViewportProps>(function EditorViewport(
|
||||
{
|
||||
viewportId,
|
||||
initialCamera = { x: 0, y: 0, zoom: 1 },
|
||||
showGrid = true,
|
||||
showGizmos = false,
|
||||
clearColor,
|
||||
minZoom = 0.1,
|
||||
maxZoom = 10,
|
||||
enablePan = true,
|
||||
enableZoom = true,
|
||||
className,
|
||||
onCameraChange,
|
||||
onReady,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
onWheel,
|
||||
renderOverlays
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
// Camera state
|
||||
const [camera, setCamera] = useState<ViewportCameraConfig>(initialCamera);
|
||||
const cameraRef = useRef(camera);
|
||||
|
||||
// Drag state
|
||||
const isDraggingRef = useRef(false);
|
||||
const lastMousePosRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
// Keep camera ref in sync
|
||||
useEffect(() => {
|
||||
cameraRef.current = camera;
|
||||
}, [camera]);
|
||||
|
||||
// Screen to world conversion
|
||||
const screenToWorld = useCallback((screenX: number, screenY: number): { x: number; y: number } => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// Convert to canvas pixel coordinates
|
||||
const canvasX = (screenX - rect.left) * dpr;
|
||||
const canvasY = (screenY - rect.top) * dpr;
|
||||
|
||||
// Convert to centered coordinates (Y-up)
|
||||
const centeredX = canvasX - canvas.width / 2;
|
||||
const centeredY = canvas.height / 2 - canvasY;
|
||||
|
||||
// Apply inverse zoom and add camera position
|
||||
const cam = cameraRef.current;
|
||||
const worldX = centeredX / cam.zoom + cam.x;
|
||||
const worldY = centeredY / cam.zoom + cam.y;
|
||||
|
||||
return { x: worldX, y: worldY };
|
||||
}, []);
|
||||
|
||||
// World to screen conversion
|
||||
const worldToScreen = useCallback((worldX: number, worldY: number): { x: number; y: number } => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cam = cameraRef.current;
|
||||
|
||||
// Apply camera transform
|
||||
const centeredX = (worldX - cam.x) * cam.zoom;
|
||||
const centeredY = (worldY - cam.y) * cam.zoom;
|
||||
|
||||
// Convert from centered coordinates
|
||||
const canvasX = centeredX + canvas.width / 2;
|
||||
const canvasY = canvas.height / 2 - centeredY;
|
||||
|
||||
// Convert to screen coordinates
|
||||
const screenX = canvasX / dpr + rect.left;
|
||||
const screenY = canvasY / dpr + rect.top;
|
||||
|
||||
return { x: screenX, y: screenY };
|
||||
}, []);
|
||||
|
||||
// Request render
|
||||
const requestRender = useCallback(() => {
|
||||
const viewportService = ViewportService.getInstance();
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.renderToViewport(viewportId);
|
||||
}
|
||||
}, [viewportId]);
|
||||
|
||||
// Expose imperative handle
|
||||
useImperativeHandle(ref, () => ({
|
||||
getCamera: () => cameraRef.current,
|
||||
setCamera: (newCamera: ViewportCameraConfig) => {
|
||||
setCamera(newCamera);
|
||||
onCameraChange?.(newCamera);
|
||||
},
|
||||
resetCamera: () => {
|
||||
setCamera(initialCamera);
|
||||
onCameraChange?.(initialCamera);
|
||||
},
|
||||
screenToWorld,
|
||||
worldToScreen,
|
||||
getCanvas: () => canvasRef.current,
|
||||
requestRender
|
||||
}), [initialCamera, screenToWorld, worldToScreen, onCameraChange, requestRender]);
|
||||
|
||||
// Initialize viewport
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const canvasId = `editor-viewport-canvas-${viewportId}`;
|
||||
canvas.id = canvasId;
|
||||
|
||||
const viewportService = ViewportService.getInstance();
|
||||
|
||||
// Wait for service to be initialized
|
||||
const checkInit = () => {
|
||||
if (viewportService.isInitialized()) {
|
||||
// Register viewport
|
||||
viewportService.registerViewport(viewportId, canvasId);
|
||||
viewportService.setViewportConfig(viewportId, showGrid, showGizmos);
|
||||
viewportService.setViewportCamera(viewportId, camera);
|
||||
|
||||
setIsReady(true);
|
||||
onReady?.();
|
||||
} else {
|
||||
// Retry after a short delay
|
||||
setTimeout(checkInit, 100);
|
||||
}
|
||||
};
|
||||
|
||||
checkInit();
|
||||
|
||||
return () => {
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.unregisterViewport(viewportId);
|
||||
}
|
||||
};
|
||||
}, [viewportId]);
|
||||
|
||||
// Update viewport config when props change
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
|
||||
const viewportService = ViewportService.getInstance();
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.setViewportConfig(viewportId, showGrid, showGizmos);
|
||||
}
|
||||
}, [viewportId, showGrid, showGizmos, isReady]);
|
||||
|
||||
// Sync camera to viewport service
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
|
||||
const viewportService = ViewportService.getInstance();
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.setViewportCamera(viewportId, camera);
|
||||
}
|
||||
}, [viewportId, camera, isReady]);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const resizeCanvas = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
|
||||
if (isReady) {
|
||||
const viewportService = ViewportService.getInstance();
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.resizeViewport(viewportId, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
resizeCanvas();
|
||||
|
||||
let rafId: number | null = null;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
resizeCanvas();
|
||||
rafId = null;
|
||||
});
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [viewportId, isReady]);
|
||||
|
||||
// Mouse handlers
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
|
||||
// Middle or right button for camera pan
|
||||
if (enablePan && (e.button === 1 || e.button === 2)) {
|
||||
isDraggingRef.current = true;
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
onMouseDown?.(e, worldPos);
|
||||
}, [enablePan, screenToWorld, onMouseDown]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
|
||||
if (isDraggingRef.current && enablePan) {
|
||||
const deltaX = e.clientX - lastMousePosRef.current.x;
|
||||
const deltaY = e.clientY - lastMousePosRef.current.y;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
setCamera(prev => {
|
||||
const newCamera = {
|
||||
...prev,
|
||||
x: prev.x - (deltaX * dpr) / prev.zoom,
|
||||
y: prev.y + (deltaY * dpr) / prev.zoom
|
||||
};
|
||||
onCameraChange?.(newCamera);
|
||||
return newCamera;
|
||||
});
|
||||
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
onMouseMove?.(e, worldPos);
|
||||
}, [enablePan, screenToWorld, onMouseMove, onCameraChange]);
|
||||
|
||||
const handleMouseUp = useCallback((e: React.MouseEvent) => {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
isDraggingRef.current = false;
|
||||
onMouseUp?.(e, worldPos);
|
||||
}, [screenToWorld, onMouseUp]);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
|
||||
if (enableZoom) {
|
||||
e.preventDefault();
|
||||
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
|
||||
setCamera(prev => {
|
||||
const newZoom = Math.max(minZoom, Math.min(maxZoom, prev.zoom * zoomFactor));
|
||||
const newCamera = { ...prev, zoom: newZoom };
|
||||
onCameraChange?.(newCamera);
|
||||
return newCamera;
|
||||
});
|
||||
}
|
||||
|
||||
onWheel?.(e, worldPos);
|
||||
}, [enableZoom, minZoom, maxZoom, screenToWorld, onWheel, onCameraChange]);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`editor-viewport ${className || ''}`}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="editor-viewport-canvas"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
{renderOverlays?.()}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default EditorViewport;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,7 @@ interface MenuBarProps {
|
||||
onOpenAbout?: () => void;
|
||||
onCreatePlugin?: () => void;
|
||||
onReloadPlugins?: () => void;
|
||||
onOpenBuildSettings?: () => void;
|
||||
}
|
||||
|
||||
export function MenuBar({
|
||||
@@ -55,7 +56,8 @@ export function MenuBar({
|
||||
onToggleDevtools,
|
||||
onOpenAbout,
|
||||
onCreatePlugin,
|
||||
onReloadPlugins
|
||||
onReloadPlugins,
|
||||
onOpenBuildSettings
|
||||
}: MenuBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
||||
@@ -129,7 +131,8 @@ export function MenuBar({
|
||||
documentation: 'Documentation',
|
||||
checkForUpdates: 'Check for Updates',
|
||||
about: 'About',
|
||||
devtools: 'Developer Tools'
|
||||
devtools: 'Developer Tools',
|
||||
buildSettings: 'Build Settings'
|
||||
},
|
||||
zh: {
|
||||
file: '文件',
|
||||
@@ -164,7 +167,8 @@ export function MenuBar({
|
||||
documentation: '文档',
|
||||
checkForUpdates: '检查更新',
|
||||
about: '关于',
|
||||
devtools: '开发者工具'
|
||||
devtools: '开发者工具',
|
||||
buildSettings: '构建设置'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || key;
|
||||
@@ -178,6 +182,8 @@ export function MenuBar({
|
||||
{ label: t('saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene },
|
||||
{ label: t('saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs },
|
||||
{ separator: true },
|
||||
{ label: t('buildSettings'), shortcut: 'Ctrl+Shift+B', onClick: onOpenBuildSettings },
|
||||
{ separator: true },
|
||||
{ label: t('openProject'), onClick: onOpenProject },
|
||||
{ label: t('closeProject'), onClick: onCloseProject },
|
||||
{ separator: true },
|
||||
|
||||
305
packages/editor-app/src/components/ModuleListSetting.tsx
Normal file
305
packages/editor-app/src/components/ModuleListSetting.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Module List Setting Component.
|
||||
* 模块列表设置组件。
|
||||
*
|
||||
* Renders a list of engine modules with checkboxes to enable/disable.
|
||||
* 渲染引擎模块列表,带复选框以启用/禁用。
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { ChevronDown, ChevronRight, Package, AlertCircle } from 'lucide-react';
|
||||
import type { ModuleManifest, ModuleCategory } from '@esengine/editor-core';
|
||||
import './styles/ModuleListSetting.css';
|
||||
|
||||
/**
|
||||
* Module entry with enabled state.
|
||||
* 带启用状态的模块条目。
|
||||
*/
|
||||
interface ModuleEntry extends ModuleManifest {
|
||||
enabled: boolean;
|
||||
canDisable: boolean;
|
||||
disableReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for ModuleListSetting.
|
||||
*/
|
||||
interface ModuleListSettingProps {
|
||||
/** Module manifests (static) | 模块清单列表(静态) */
|
||||
modules?: ModuleManifest[];
|
||||
/** Function to get modules dynamically (sizes from module.json) | 动态获取模块的函数(大小来自 module.json) */
|
||||
getModules?: () => ModuleManifest[];
|
||||
/**
|
||||
* Module IDs list. Meaning depends on useBlacklist.
|
||||
* 模块 ID 列表。含义取决于 useBlacklist。
|
||||
* - useBlacklist=false: enabled modules (whitelist)
|
||||
* - useBlacklist=true: disabled modules (blacklist)
|
||||
*/
|
||||
value: string[];
|
||||
/** Callback when modules change | 模块变更回调 */
|
||||
onModulesChange: (moduleIds: string[]) => void;
|
||||
/**
|
||||
* Use blacklist mode: value contains disabled modules instead of enabled.
|
||||
* 使用黑名单模式:value 包含禁用的模块而不是启用的。
|
||||
* Default: false (whitelist mode)
|
||||
*/
|
||||
useBlacklist?: boolean;
|
||||
/** Validate if module can be disabled | 验证模块是否可禁用 */
|
||||
validateDisable?: (moduleId: string) => Promise<{ canDisable: boolean; reason?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable string.
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module List Setting Component.
|
||||
* 模块列表设置组件。
|
||||
*/
|
||||
export const ModuleListSetting: React.FC<ModuleListSettingProps> = ({
|
||||
modules: staticModules,
|
||||
getModules,
|
||||
value,
|
||||
onModulesChange,
|
||||
useBlacklist = false,
|
||||
validateDisable
|
||||
}) => {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['Core', 'Rendering']));
|
||||
const [validationError, setValidationError] = useState<{ moduleId: string; message: string } | null>(null);
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
|
||||
// Get modules from function or static prop
|
||||
// 从函数或静态 prop 获取模块
|
||||
const modules = useMemo(() => {
|
||||
if (getModules) {
|
||||
return getModules();
|
||||
}
|
||||
return staticModules || [];
|
||||
}, [getModules, staticModules]);
|
||||
|
||||
// Build module entries with enabled state | 构建带启用状态的模块条目
|
||||
// In blacklist mode: enabled = NOT in value list
|
||||
// In whitelist mode: enabled = IN value list
|
||||
const moduleEntries: ModuleEntry[] = useMemo(() => {
|
||||
return modules.map(mod => {
|
||||
let enabled: boolean;
|
||||
if (mod.isCore) {
|
||||
enabled = true; // Core modules always enabled
|
||||
} else if (useBlacklist) {
|
||||
enabled = !value.includes(mod.id); // Blacklist: NOT in list = enabled
|
||||
} else {
|
||||
enabled = value.includes(mod.id); // Whitelist: IN list = enabled
|
||||
}
|
||||
return {
|
||||
...mod,
|
||||
enabled,
|
||||
canDisable: !mod.isCore,
|
||||
disableReason: mod.isCore ? 'Core module cannot be disabled' : undefined
|
||||
};
|
||||
});
|
||||
}, [modules, value, useBlacklist]);
|
||||
|
||||
// Group by category | 按分类分组
|
||||
const groupedModules = useMemo(() => {
|
||||
const groups = new Map<string, ModuleEntry[]>();
|
||||
const categoryOrder: ModuleCategory[] = ['Core', 'Rendering', 'Physics', 'AI', 'Audio', 'Networking', 'Other'];
|
||||
|
||||
// Initialize groups | 初始化分组
|
||||
for (const cat of categoryOrder) {
|
||||
groups.set(cat, []);
|
||||
}
|
||||
|
||||
// Group modules | 分组模块
|
||||
for (const mod of moduleEntries) {
|
||||
const cat = mod.category || 'Other';
|
||||
if (!groups.has(cat)) {
|
||||
groups.set(cat, []);
|
||||
}
|
||||
groups.get(cat)!.push(mod);
|
||||
}
|
||||
|
||||
// Filter empty groups | 过滤空分组
|
||||
const result = new Map<string, ModuleEntry[]>();
|
||||
for (const [cat, mods] of groups) {
|
||||
if (mods.length > 0) {
|
||||
result.set(cat, mods);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [moduleEntries]);
|
||||
|
||||
// Calculate total size (JS + WASM) | 计算总大小(JS + WASM)
|
||||
const { totalJsSize, totalWasmSize, totalSize } = useMemo(() => {
|
||||
let js = 0;
|
||||
let wasm = 0;
|
||||
for (const m of moduleEntries) {
|
||||
if (m.enabled) {
|
||||
js += m.jsSize || 0;
|
||||
wasm += m.wasmSize || 0;
|
||||
}
|
||||
}
|
||||
return { totalJsSize: js, totalWasmSize: wasm, totalSize: js + wasm };
|
||||
}, [moduleEntries]);
|
||||
|
||||
// Toggle category expansion | 切换分类展开
|
||||
const toggleCategory = useCallback((category: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) {
|
||||
next.delete(category);
|
||||
} else {
|
||||
next.add(category);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle module toggle | 处理模块切换
|
||||
const handleModuleToggle = useCallback(async (module: ModuleEntry, enabled: boolean) => {
|
||||
if (module.isCore) return;
|
||||
|
||||
// If disabling, validate first | 如果禁用,先验证
|
||||
if (!enabled && validateDisable) {
|
||||
setLoading(module.id);
|
||||
try {
|
||||
const result = await validateDisable(module.id);
|
||||
if (!result.canDisable) {
|
||||
setValidationError({
|
||||
moduleId: module.id,
|
||||
message: result.reason || `Cannot disable ${module.displayName}`
|
||||
});
|
||||
setLoading(null);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Update module list based on mode
|
||||
let newValue: string[];
|
||||
|
||||
if (useBlacklist) {
|
||||
// Blacklist mode: value contains disabled modules
|
||||
if (enabled) {
|
||||
// Remove from blacklist (and also remove dependencies)
|
||||
const toRemove = new Set([module.id]);
|
||||
// Also enable dependencies if they were disabled
|
||||
for (const depId of module.dependencies) {
|
||||
toRemove.add(depId);
|
||||
}
|
||||
newValue = value.filter(id => !toRemove.has(id));
|
||||
} else {
|
||||
// Add to blacklist
|
||||
newValue = [...value, module.id];
|
||||
}
|
||||
} else {
|
||||
// Whitelist mode: value contains enabled modules
|
||||
if (enabled) {
|
||||
// Add to whitelist (and dependencies)
|
||||
newValue = [...value];
|
||||
const toEnable = [module.id, ...module.dependencies];
|
||||
for (const id of toEnable) {
|
||||
if (!newValue.includes(id)) {
|
||||
newValue.push(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remove from whitelist
|
||||
newValue = value.filter(id => id !== module.id);
|
||||
}
|
||||
}
|
||||
|
||||
onModulesChange(newValue);
|
||||
}, [value, useBlacklist, onModulesChange, validateDisable]);
|
||||
|
||||
return (
|
||||
<div className="module-list-setting">
|
||||
{/* Module categories | 模块分类 */}
|
||||
<div className="module-list-categories">
|
||||
{Array.from(groupedModules.entries()).map(([category, mods]) => (
|
||||
<div key={category} className="module-category-group">
|
||||
<div
|
||||
className="module-category-header"
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
{expandedCategories.has(category) ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
<span className="module-category-name">{category}</span>
|
||||
<span className="module-category-count">
|
||||
{mods.filter(m => m.enabled).length}/{mods.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedCategories.has(category) && (
|
||||
<div className="module-category-items">
|
||||
{mods.map(mod => (
|
||||
<div
|
||||
key={mod.id}
|
||||
className={`module-item ${mod.enabled ? 'enabled' : ''} ${loading === mod.id ? 'loading' : ''}`}
|
||||
>
|
||||
<label className="module-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mod.enabled}
|
||||
disabled={mod.isCore || loading === mod.id}
|
||||
onChange={(e) => handleModuleToggle(mod, e.target.checked)}
|
||||
/>
|
||||
<Package size={14} className="module-icon" />
|
||||
<span className="module-name">{mod.displayName}</span>
|
||||
{mod.isCore && (
|
||||
<span className="module-badge core">Core</span>
|
||||
)}
|
||||
</label>
|
||||
{(mod.jsSize || mod.wasmSize) ? (
|
||||
<span className="module-size">
|
||||
{mod.isCore ? '' : '+'}
|
||||
{formatBytes((mod.jsSize || 0) + (mod.wasmSize || 0))}
|
||||
{(mod.wasmSize ?? 0) > 0 && (
|
||||
<span className="module-wasm-indicator" title="Includes WASM">⚡</span>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Size footer | 大小页脚 */}
|
||||
<div className="module-list-footer">
|
||||
<span className="module-list-size-label">Runtime size:</span>
|
||||
<span className="module-list-size-value">
|
||||
{formatBytes(totalSize)}
|
||||
{totalWasmSize > 0 && (
|
||||
<span className="module-size-breakdown">
|
||||
(JS: {formatBytes(totalJsSize)} + WASM: {formatBytes(totalWasmSize)})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Validation error toast | 验证错误提示 */}
|
||||
{validationError && (
|
||||
<div className="module-validation-error">
|
||||
<AlertCircle size={14} />
|
||||
<span>{validationError.message}</span>
|
||||
<button onClick={() => setValidationError(null)}>Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModuleListSetting;
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { PluginManager, type RegisteredPlugin, type PluginCategory, ProjectService } from '@esengine/editor-core';
|
||||
import { PluginManager, type RegisteredPlugin, type ModuleCategory, ProjectService } from '@esengine/editor-core';
|
||||
import { Check, Lock, Package } from 'lucide-react';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import '../styles/PluginListSetting.css';
|
||||
@@ -20,21 +20,17 @@ interface PluginListSettingProps {
|
||||
pluginManager: PluginManager;
|
||||
}
|
||||
|
||||
const categoryLabels: Record<PluginCategory, { zh: string; en: string }> = {
|
||||
core: { zh: '核心', en: 'Core' },
|
||||
rendering: { zh: '渲染', en: 'Rendering' },
|
||||
ui: { zh: 'UI', en: 'UI' },
|
||||
ai: { zh: 'AI', en: 'AI' },
|
||||
physics: { zh: '物理', en: 'Physics' },
|
||||
audio: { zh: '音频', en: 'Audio' },
|
||||
networking: { zh: '网络', en: 'Networking' },
|
||||
tools: { zh: '工具', en: 'Tools' },
|
||||
scripting: { zh: '脚本', en: 'Scripting' },
|
||||
content: { zh: '内容', en: 'Content' },
|
||||
tilemap: { zh: '瓦片地图', en: 'Tilemap' }
|
||||
const categoryLabels: Record<ModuleCategory, { zh: string; en: string }> = {
|
||||
Core: { zh: '核心', en: 'Core' },
|
||||
Rendering: { zh: '渲染', en: 'Rendering' },
|
||||
Physics: { zh: '物理', en: 'Physics' },
|
||||
AI: { zh: 'AI', en: 'AI' },
|
||||
Audio: { zh: '音频', en: 'Audio' },
|
||||
Networking: { zh: '网络', en: 'Networking' },
|
||||
Other: { zh: '其他', en: 'Other' }
|
||||
};
|
||||
|
||||
const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'scripting', 'physics', 'audio', 'networking', 'tilemap', 'tools', 'content'];
|
||||
const categoryOrder: ModuleCategory[] = ['Core', 'Rendering', 'Physics', 'AI', 'Audio', 'Networking', 'Other'];
|
||||
|
||||
export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
|
||||
@@ -56,13 +52,13 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
};
|
||||
|
||||
const handleToggle = async (pluginId: string) => {
|
||||
const plugin = plugins.find(p => p.plugin.descriptor.id === pluginId);
|
||||
const plugin = plugins.find(p => p.plugin.manifest.id === pluginId);
|
||||
if (!plugin) return;
|
||||
|
||||
const descriptor = plugin.plugin.descriptor;
|
||||
const manifest = plugin.plugin.manifest;
|
||||
|
||||
// 核心插件不可禁用
|
||||
if (descriptor.isCore) {
|
||||
if (manifest.isCore) {
|
||||
showWarning('核心插件不可禁用');
|
||||
return;
|
||||
}
|
||||
@@ -71,14 +67,14 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
|
||||
// 检查依赖(启用时)
|
||||
if (newEnabled) {
|
||||
const deps = descriptor.dependencies || [];
|
||||
const missingDeps = deps.filter(dep => {
|
||||
const depPlugin = plugins.find(p => p.plugin.descriptor.id === dep.id);
|
||||
const deps = manifest.dependencies || [];
|
||||
const missingDeps = deps.filter((depId: string) => {
|
||||
const depPlugin = plugins.find(p => p.plugin.manifest.id === depId);
|
||||
return depPlugin && !depPlugin.enabled;
|
||||
});
|
||||
|
||||
if (missingDeps.length > 0) {
|
||||
showWarning(`需要先启用依赖插件: ${missingDeps.map(d => d.id).join(', ')}`);
|
||||
showWarning(`需要先启用依赖插件: ${missingDeps.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -100,7 +96,7 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
|
||||
// 更新本地状态
|
||||
setPlugins(plugins.map(p => {
|
||||
if (p.plugin.descriptor.id === pluginId) {
|
||||
if (p.plugin.manifest.id === pluginId) {
|
||||
return { ...p, enabled: newEnabled };
|
||||
}
|
||||
return p;
|
||||
@@ -115,7 +111,7 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
|
||||
if (notificationService) {
|
||||
notificationService.show(
|
||||
newEnabled ? `已启用插件: ${descriptor.name}` : `已禁用插件: ${descriptor.name}`,
|
||||
newEnabled ? `已启用插件: ${manifest.displayName}` : `已禁用插件: ${manifest.displayName}`,
|
||||
'success',
|
||||
2000
|
||||
);
|
||||
@@ -135,8 +131,8 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
|
||||
// 获取当前启用的插件列表(排除核心插件)
|
||||
const enabledPlugins = pluginManager.getEnabledPlugins()
|
||||
.filter(p => !p.plugin.descriptor.isCore)
|
||||
.map(p => p.plugin.descriptor.id);
|
||||
.filter(p => !p.plugin.manifest.isCore)
|
||||
.map(p => p.plugin.manifest.id);
|
||||
|
||||
console.log('[PluginListSetting] Saving enabled plugins:', enabledPlugins);
|
||||
|
||||
@@ -150,13 +146,13 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
|
||||
// 按类别分组并排序
|
||||
const groupedPlugins = plugins.reduce((acc, plugin) => {
|
||||
const category = plugin.plugin.descriptor.category;
|
||||
const category = plugin.plugin.manifest.category;
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(plugin);
|
||||
return acc;
|
||||
}, {} as Record<PluginCategory, RegisteredPlugin[]>);
|
||||
}, {} as Record<ModuleCategory, RegisteredPlugin[]>);
|
||||
|
||||
// 按照 categoryOrder 排序
|
||||
const sortedCategories = categoryOrder.filter(cat => groupedPlugins[cat]?.length > 0);
|
||||
@@ -169,19 +165,19 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
{categoryLabels[category]?.zh || category}
|
||||
</div>
|
||||
<div className="plugin-list">
|
||||
{groupedPlugins[category].map(plugin => {
|
||||
const descriptor = plugin.plugin.descriptor;
|
||||
{groupedPlugins[category]?.map(plugin => {
|
||||
const manifest = plugin.plugin.manifest;
|
||||
const hasRuntime = !!plugin.plugin.runtimeModule;
|
||||
const hasEditor = !!plugin.plugin.editorModule;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={descriptor.id}
|
||||
className={`plugin-item ${plugin.enabled ? 'enabled' : ''} ${descriptor.isCore ? 'core' : ''}`}
|
||||
onClick={() => handleToggle(descriptor.id)}
|
||||
key={manifest.id}
|
||||
className={`plugin-item ${plugin.enabled ? 'enabled' : ''} ${manifest.isCore ? 'core' : ''}`}
|
||||
onClick={() => handleToggle(manifest.id)}
|
||||
>
|
||||
<div className="plugin-checkbox">
|
||||
{descriptor.isCore ? (
|
||||
{manifest.isCore ? (
|
||||
<Lock size={10} />
|
||||
) : (
|
||||
plugin.enabled && <Check size={10} />
|
||||
@@ -189,12 +185,12 @@ export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
</div>
|
||||
<div className="plugin-info">
|
||||
<div className="plugin-header">
|
||||
<span className="plugin-name">{descriptor.name}</span>
|
||||
<span className="plugin-version">v{descriptor.version}</span>
|
||||
<span className="plugin-name">{manifest.displayName}</span>
|
||||
<span className="plugin-version">v{manifest.version}</span>
|
||||
</div>
|
||||
{descriptor.description && (
|
||||
{manifest.description && (
|
||||
<div className="plugin-description">
|
||||
{descriptor.description}
|
||||
{manifest.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="plugin-modules">
|
||||
|
||||
@@ -928,11 +928,27 @@ function ContextMenuWithSubmenu({
|
||||
'other': { zh: '其他', en: 'Other' },
|
||||
};
|
||||
|
||||
// 实体创建模板的 label 本地化映射
|
||||
const entityTemplateLabels: Record<string, { zh: string; en: string }> = {
|
||||
'Sprite': { zh: '精灵', en: 'Sprite' },
|
||||
'Animated Sprite': { zh: '动画精灵', en: 'Animated Sprite' },
|
||||
'创建 Tilemap': { zh: '瓦片地图', en: 'Tilemap' },
|
||||
'Camera 2D': { zh: '2D 相机', en: 'Camera 2D' },
|
||||
};
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const labels = categoryLabels[category];
|
||||
return labels ? (locale === 'zh' ? labels.zh : labels.en) : category;
|
||||
};
|
||||
|
||||
const getEntityTemplateLabel = (label: string) => {
|
||||
const mapping = entityTemplateLabels[label];
|
||||
if (mapping) {
|
||||
return locale === 'zh' ? mapping.zh : mapping.en;
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
const templatesByCategory = pluginTemplates.reduce((acc, template) => {
|
||||
const cat = template.category || 'other';
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
@@ -996,7 +1012,7 @@ function ContextMenuWithSubmenu({
|
||||
{templates.map((template) => (
|
||||
<button key={template.id} onClick={() => onCreateFromTemplate(template)}>
|
||||
{getIconComponent(template.icon as string, 12)}
|
||||
<span>{template.label}</span>
|
||||
<span>{getEntityTemplateLabel(template.label)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { SettingsService } from '../services/SettingsService';
|
||||
import { SettingsRegistry, SettingCategory, SettingDescriptor, ProjectService, PluginManager, IPluginManager } from '@esengine/editor-core';
|
||||
import { SettingsRegistry, SettingCategory, SettingDescriptor, ProjectService, PluginManager, IPluginManager, ModuleManifest } from '@esengine/editor-core';
|
||||
import { PluginListSetting } from './PluginListSetting';
|
||||
import { ModuleListSetting } from './ModuleListSetting';
|
||||
import '../styles/SettingsWindow.css';
|
||||
|
||||
interface SettingsWindowProps {
|
||||
@@ -142,6 +143,9 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
} else if (key === 'project.uiDesignResolution.preset') {
|
||||
const resolution = projectService.getUIDesignResolution();
|
||||
initialValues.set(key, `${resolution.width}x${resolution.height}`);
|
||||
} else if (key === 'project.disabledModules') {
|
||||
// Load disabled modules from ProjectService
|
||||
initialValues.set(key, projectService.getDisabledModules());
|
||||
} else {
|
||||
initialValues.set(key, descriptor.defaultValue);
|
||||
}
|
||||
@@ -199,6 +203,8 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
let uiResolutionChanged = false;
|
||||
let newWidth = 1920;
|
||||
let newHeight = 1080;
|
||||
let disabledModulesChanged = false;
|
||||
let newDisabledModules: string[] = [];
|
||||
|
||||
for (const [key, value] of values.entries()) {
|
||||
if (key.startsWith('project.') && projectService) {
|
||||
@@ -215,6 +221,9 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
newHeight = h;
|
||||
uiResolutionChanged = true;
|
||||
}
|
||||
} else if (key === 'project.disabledModules') {
|
||||
newDisabledModules = value as string[];
|
||||
disabledModulesChanged = true;
|
||||
}
|
||||
changedSettings[key] = value;
|
||||
} else {
|
||||
@@ -227,6 +236,10 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
|
||||
}
|
||||
|
||||
if (disabledModulesChanged && projectService) {
|
||||
await projectService.setDisabledModules(newDisabledModules);
|
||||
}
|
||||
|
||||
console.log('[SettingsWindow] Saving settings, changedSettings:', changedSettings);
|
||||
window.dispatchEvent(new CustomEvent('settings:changed', {
|
||||
detail: changedSettings
|
||||
@@ -487,6 +500,31 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
);
|
||||
}
|
||||
|
||||
case 'moduleList': {
|
||||
// Get module data from setting's custom props
|
||||
// 从设置的自定义属性获取模块数据
|
||||
const moduleData = setting as SettingDescriptor & {
|
||||
modules?: ModuleManifest[];
|
||||
getModules?: () => ModuleManifest[];
|
||||
useBlacklist?: boolean;
|
||||
validateDisable?: (moduleId: string) => Promise<{ canDisable: boolean; reason?: string }>;
|
||||
};
|
||||
const moduleValue = value as string[] || [];
|
||||
|
||||
return (
|
||||
<div className="settings-module-list">
|
||||
<ModuleListSetting
|
||||
modules={moduleData.modules}
|
||||
getModules={moduleData.getModules}
|
||||
value={moduleValue}
|
||||
onModulesChange={(newValue) => handleValueChange(setting.key, newValue, setting)}
|
||||
useBlacklist={moduleData.useBlacklist}
|
||||
validateDisable={moduleData.validateDisable}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ interface TitleBarProps {
|
||||
onOpenAbout?: () => void;
|
||||
onCreatePlugin?: () => void;
|
||||
onReloadPlugins?: () => void;
|
||||
onOpenBuildSettings?: () => void;
|
||||
}
|
||||
|
||||
export function TitleBar({
|
||||
@@ -58,7 +59,8 @@ export function TitleBar({
|
||||
onToggleDevtools,
|
||||
onOpenAbout,
|
||||
onCreatePlugin,
|
||||
onReloadPlugins
|
||||
onReloadPlugins,
|
||||
onOpenBuildSettings
|
||||
}: TitleBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
||||
@@ -152,7 +154,8 @@ export function TitleBar({
|
||||
documentation: 'Documentation',
|
||||
checkForUpdates: 'Check for Updates',
|
||||
about: 'About',
|
||||
devtools: 'Developer Tools'
|
||||
devtools: 'Developer Tools',
|
||||
buildSettings: 'Build Settings'
|
||||
},
|
||||
zh: {
|
||||
file: '文件',
|
||||
@@ -187,7 +190,8 @@ export function TitleBar({
|
||||
documentation: '文档',
|
||||
checkForUpdates: '检查更新',
|
||||
about: '关于',
|
||||
devtools: '开发者工具'
|
||||
devtools: '开发者工具',
|
||||
buildSettings: '构建设置'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || key;
|
||||
@@ -201,6 +205,8 @@ export function TitleBar({
|
||||
{ label: t('saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene },
|
||||
{ label: t('saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs },
|
||||
{ separator: true },
|
||||
{ label: t('buildSettings'), shortcut: 'Ctrl+Shift+B', onClick: onOpenBuildSettings },
|
||||
{ separator: true },
|
||||
{ label: t('openProject'), onClick: onOpenProject },
|
||||
{ label: t('closeProject'), onClick: onCloseProject },
|
||||
{ separator: true },
|
||||
|
||||
@@ -17,69 +17,139 @@ import { open } from '@tauri-apps/plugin-shell';
|
||||
import { RuntimeResolver } from '../services/RuntimeResolver';
|
||||
import { QRCodeDialog } from './QRCodeDialog';
|
||||
|
||||
// Generate runtime HTML for browser preview
|
||||
function generateRuntimeHtml(): string {
|
||||
import type { ModuleManifest } from '../services/RuntimeResolver';
|
||||
|
||||
/**
|
||||
* Generate runtime HTML for browser preview using ES Modules with import maps
|
||||
* 使用 ES 模块和 import maps 生成浏览器预览的运行时 HTML
|
||||
*
|
||||
* This matches the structure of published builds for consistency
|
||||
* 这与发布构建的结构一致
|
||||
*/
|
||||
function generateRuntimeHtml(importMap: Record<string, string>, modules: ModuleManifest[]): string {
|
||||
const importMapScript = `<script type="importmap">
|
||||
${JSON.stringify({ imports: importMap }, null, 2).split('\n').join('\n ')}
|
||||
</script>`;
|
||||
|
||||
// Generate plugin import code for modules with pluginExport
|
||||
// Only modules with pluginExport are considered runtime plugins
|
||||
// Core/infrastructure modules don't need to be registered as plugins
|
||||
const pluginModules = modules.filter(m => m.pluginExport);
|
||||
|
||||
const pluginImportCode = pluginModules.map(m =>
|
||||
` try {
|
||||
const { ${m.pluginExport} } = await import('@esengine/${m.id}');
|
||||
runtime.registerPlugin(${m.pluginExport});
|
||||
} catch (e) {
|
||||
console.warn('[Preview] Failed to load plugin ${m.id}:', e.message);
|
||||
}`
|
||||
).join('\n');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>ECS Runtime Preview</title>
|
||||
${importMapScript}
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
background: #1e1e1e;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; }
|
||||
#game-canvas { width: 100%; height: 100%; display: block; }
|
||||
#loading {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
background: #1a1a2e; color: #eee; font-family: sans-serif;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
#loading .spinner {
|
||||
width: 40px; height: 40px; border: 3px solid #333;
|
||||
border-top-color: #4a9eff; border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
#loading .message { margin-top: 16px; font-size: 14px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#error {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
display: none; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
background: #1a1a2e; color: #ff6b6b; font-family: sans-serif;
|
||||
padding: 20px; text-align: center;
|
||||
}
|
||||
#error.show { display: flex; }
|
||||
#error h2 { margin-bottom: 16px; }
|
||||
#error pre {
|
||||
background: rgba(0,0,0,0.3); padding: 16px; border-radius: 8px;
|
||||
max-width: 600px; white-space: pre-wrap; word-break: break-word;
|
||||
font-size: 13px; line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="runtime-canvas"></canvas>
|
||||
<script src="/runtime.browser.js"></script>
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
<div class="message" id="loading-message">Loading...</div>
|
||||
</div>
|
||||
<div id="error">
|
||||
<h2 id="error-title">Failed to start</h2>
|
||||
<pre id="error-message"></pre>
|
||||
</div>
|
||||
<canvas id="game-canvas"></canvas>
|
||||
|
||||
<script type="module">
|
||||
import * as esEngine from '/es_engine.js';
|
||||
(async function() {
|
||||
try {
|
||||
// Set canvas size before creating runtime
|
||||
const canvas = document.getElementById('runtime-canvas');
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
const loading = document.getElementById('loading');
|
||||
const loadingMessage = document.getElementById('loading-message');
|
||||
const errorDiv = document.getElementById('error');
|
||||
const errorTitle = document.getElementById('error-title');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
|
||||
const runtime = ECSRuntime.create({
|
||||
canvasId: 'runtime-canvas',
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
projectConfigUrl: '/ecs-editor.config.json'
|
||||
});
|
||||
function showError(title, msg) {
|
||||
loading.style.display = 'none';
|
||||
errorTitle.textContent = title || 'Failed to start';
|
||||
errorMessage.textContent = msg;
|
||||
errorDiv.classList.add('show');
|
||||
console.error('[Preview]', msg);
|
||||
}
|
||||
|
||||
await runtime.initialize(esEngine);
|
||||
await runtime.loadScene('/scene.json?_=' + Date.now());
|
||||
runtime.start();
|
||||
function updateLoading(msg) {
|
||||
loadingMessage.textContent = msg;
|
||||
console.log('[Preview]', msg);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const canvas = document.getElementById('runtime-canvas');
|
||||
const newWidth = window.innerWidth;
|
||||
const newHeight = window.innerHeight;
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
runtime.handleResize(newWidth, newHeight);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Runtime error:', e);
|
||||
}
|
||||
})();
|
||||
try {
|
||||
updateLoading('Loading runtime...');
|
||||
const ECSRuntime = (await import('@esengine/platform-web')).default;
|
||||
|
||||
updateLoading('Loading WASM module...');
|
||||
const wasmModule = await import('./libs/es-engine/es_engine.js');
|
||||
|
||||
updateLoading('Initializing runtime...');
|
||||
const runtime = ECSRuntime.create({
|
||||
canvasId: 'game-canvas',
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
assetBaseUrl: './assets',
|
||||
projectConfigUrl: './ecs-editor.config.json'
|
||||
});
|
||||
|
||||
updateLoading('Loading plugins...');
|
||||
${pluginImportCode}
|
||||
|
||||
await runtime.initialize(wasmModule);
|
||||
|
||||
updateLoading('Loading scene...');
|
||||
await runtime.loadScene('./scene.json?_=' + Date.now());
|
||||
|
||||
loading.style.display = 'none';
|
||||
runtime.start();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
runtime.handleResize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
console.log('[Preview] Started successfully');
|
||||
} catch (error) {
|
||||
showError(null, error.message || String(error));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -697,13 +767,13 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
await TauriAPI.createDirectory(runtimeDir);
|
||||
}
|
||||
|
||||
// Use RuntimeResolver to copy runtime files
|
||||
// 使用 RuntimeResolver 复制运行时文件
|
||||
// Use RuntimeResolver to copy runtime files with ES Modules structure
|
||||
// 使用 RuntimeResolver 复制运行时文件(ES 模块结构)
|
||||
const runtimeResolver = RuntimeResolver.getInstance();
|
||||
await runtimeResolver.initialize();
|
||||
await runtimeResolver.prepareRuntimeFiles(runtimeDir);
|
||||
const { modules, importMap } = await runtimeResolver.prepareRuntimeFiles(runtimeDir);
|
||||
|
||||
// Write scene data and HTML (always update)
|
||||
// Write scene data
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneData);
|
||||
|
||||
// Copy project config file (for plugin settings)
|
||||
@@ -818,7 +888,8 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/asset-catalog.json`, JSON.stringify(assetCatalog, null, 2));
|
||||
console.log(`[Viewport] Asset catalog created with ${Object.keys(catalogEntries).length} entries`);
|
||||
|
||||
const runtimeHtml = generateRuntimeHtml();
|
||||
// Generate HTML with import maps (matching published build structure)
|
||||
const runtimeHtml = generateRuntimeHtml(importMap, modules);
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, runtimeHtml);
|
||||
|
||||
// Start local server and open browser
|
||||
@@ -865,10 +936,10 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
await TauriAPI.createDirectory(runtimeDir);
|
||||
}
|
||||
|
||||
// Use RuntimeResolver to copy runtime files
|
||||
// Use RuntimeResolver to copy runtime files with ES Modules structure
|
||||
const runtimeResolver = RuntimeResolver.getInstance();
|
||||
await runtimeResolver.initialize();
|
||||
await runtimeResolver.prepareRuntimeFiles(runtimeDir);
|
||||
const { modules, importMap } = await runtimeResolver.prepareRuntimeFiles(runtimeDir);
|
||||
|
||||
// Copy project config file (for plugin settings)
|
||||
const projectService = Core.services.tryResolve(ProjectService);
|
||||
@@ -883,10 +954,10 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Write scene data and HTML
|
||||
// Write scene data and HTML with import maps
|
||||
const sceneDataStr = typeof sceneData === 'string' ? sceneData : new TextDecoder().decode(sceneData);
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneDataStr);
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, generateRuntimeHtml());
|
||||
await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, generateRuntimeHtml(importMap, modules));
|
||||
|
||||
// Copy textures referenced in scene
|
||||
const assetsDir = `${runtimeDir}\\assets`;
|
||||
|
||||
@@ -99,7 +99,11 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
'conf',
|
||||
'log',
|
||||
'btree',
|
||||
'ecs'
|
||||
'ecs',
|
||||
'mat',
|
||||
'shader',
|
||||
'tilemap',
|
||||
'tileset'
|
||||
];
|
||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif'];
|
||||
const isTextFile = fileInfo.extension && textExtensions.includes(fileInfo.extension.toLowerCase());
|
||||
@@ -188,6 +192,12 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
}
|
||||
|
||||
if (target.type === 'asset-file') {
|
||||
// Check if a plugin provides a custom inspector for this asset type
|
||||
const customInspector = inspectorRegistry.render(target, { target, projectPath });
|
||||
if (customInspector) {
|
||||
return customInspector;
|
||||
}
|
||||
// Fall back to default asset file inspector
|
||||
return <AssetFileInspector fileInfo={target.data} content={target.content} isImage={target.isImage} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,16 +59,26 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[];
|
||||
|
||||
// 当 entity 变化或组件数量变化时,更新展开状态(新组件默认展开)
|
||||
// 注意:不要依赖 componentVersion,否则每次属性变化都会重置展开状态
|
||||
useEffect(() => {
|
||||
setExpandedComponents((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
// 添加所有当前组件的索引(保留已有的展开状态)
|
||||
// 只添加新增组件的索引(保留已有的展开/收缩状态)
|
||||
entity.components.forEach((_, index) => {
|
||||
newSet.add(index);
|
||||
// 只有当索引不在集合中时才添加(即新组件)
|
||||
if (!prev.has(index) && index >= prev.size) {
|
||||
newSet.add(index);
|
||||
}
|
||||
});
|
||||
// 移除不存在的索引(组件被删除的情况)
|
||||
for (const idx of prev) {
|
||||
if (idx >= entity.components.length) {
|
||||
newSet.delete(idx);
|
||||
}
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, [entity, entity.components.length, componentVersion]);
|
||||
}, [entity, entity.components.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showComponentMenu && addButtonRef.current) {
|
||||
@@ -439,6 +449,15 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
onAction={handlePropertyAction}
|
||||
/>
|
||||
}
|
||||
{/* Append-mode inspectors (shown after default inspector) */}
|
||||
{componentInspectorRegistry?.renderAppendInspectors({
|
||||
component,
|
||||
entity,
|
||||
version: componentVersion + localVersion,
|
||||
onChange: (propName: string, value: unknown) =>
|
||||
handlePropertyChange(component, propName, value),
|
||||
onAction: handlePropertyAction
|
||||
})}
|
||||
{/* Dynamic component actions from plugins */}
|
||||
{componentActionRegistry?.getActionsForComponent(componentName).map((action) => {
|
||||
// 解析图标:支持字符串(Lucide 图标名)或 React 元素
|
||||
|
||||
187
packages/editor-app/src/components/styles/ModuleListSetting.css
Normal file
187
packages/editor-app/src/components/styles/ModuleListSetting.css
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Module List Setting Styles.
|
||||
* 模块列表设置样式。
|
||||
*/
|
||||
|
||||
.module-list-setting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.module-list-categories {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Category Group */
|
||||
.module-category-group {
|
||||
border: 1px solid var(--border-color, #3a3a3a);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.module-category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary, #2a2a2a);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.module-category-header:hover {
|
||||
background: var(--bg-hover, #333);
|
||||
}
|
||||
|
||||
.module-category-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #eee);
|
||||
}
|
||||
|
||||
.module-category-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #888);
|
||||
}
|
||||
|
||||
/* Category Items */
|
||||
.module-category-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.module-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px 6px 28px;
|
||||
border-top: 1px solid var(--border-color, #3a3a3a);
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.module-item:hover {
|
||||
background: var(--bg-hover, #333);
|
||||
}
|
||||
|
||||
.module-item.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Checkbox Label */
|
||||
.module-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.module-checkbox-label input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.module-checkbox-label input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.module-icon {
|
||||
color: var(--text-secondary, #aaa);
|
||||
}
|
||||
|
||||
.module-item.enabled .module-icon {
|
||||
color: var(--accent-color, #4a9eff);
|
||||
}
|
||||
|
||||
.module-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #eee);
|
||||
}
|
||||
|
||||
.module-badge {
|
||||
padding: 1px 6px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.module-badge.core {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
color: var(--accent-color, #4a9eff);
|
||||
}
|
||||
|
||||
.module-size {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary, #888);
|
||||
}
|
||||
|
||||
.module-size-inlined {
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.module-wasm-indicator {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.module-size-breakdown {
|
||||
margin-left: 4px;
|
||||
opacity: 0.7;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.module-list-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary, #2a2a2a);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.module-list-size-label {
|
||||
color: var(--text-secondary, #aaa);
|
||||
}
|
||||
|
||||
.module-list-size-value {
|
||||
color: var(--text-primary, #eee);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Validation Error */
|
||||
.module-validation-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 107, 107, 0.15);
|
||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #ff6b6b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.module-validation-error button {
|
||||
margin-left: auto;
|
||||
padding: 2px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 3px;
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.module-validation-error button:hover {
|
||||
background: rgba(255, 107, 107, 0.2);
|
||||
}
|
||||
Reference in New Issue
Block a user