Files
esengine/packages/tilemap-editor/src/components/panels/TilemapEditorPanel.tsx
YHH b42a7b4e43 Feature/editor optimization (#251)
* refactor: 编辑器/运行时架构拆分与构建系统升级

* feat(core): 层级系统重构与UI变换矩阵修复

* refactor: 移除 ecs-components 聚合包并修复跨包组件查找问题

* fix(physics): 修复跨包组件类引用问题

* feat: 统一运行时架构与浏览器运行支持

* feat(asset): 实现浏览器运行时资产加载系统

* fix: 修复文档、CodeQL安全问题和CI类型检查错误

* fix: 修复文档、CodeQL安全问题和CI类型检查错误

* fix: 修复文档、CodeQL安全问题、CI类型检查和测试错误

* test: 补齐核心模块测试用例,修复CI构建配置

* fix: 修复测试用例中的类型错误和断言问题

* fix: 修复 turbo build:npm 任务的依赖顺序问题

* fix: 修复 CI 构建错误并优化构建性能
2025-12-01 22:28:51 +08:00

1217 lines
46 KiB
TypeScript

/**
* Tilemap Editor Panel - Main editing panel with 3-column layout
* Tilemap 编辑器面板 - 三栏布局的主编辑面板
*/
import React, { useEffect, useState, useRef, useCallback } from 'react';
import {
Grid3x3,
Eye,
EyeOff,
ZoomIn,
ZoomOut,
RotateCcw,
Map,
Save,
Scaling,
X,
Search,
Folder,
FolderOpen,
File,
Image as ImageIcon,
MousePointer2,
Move,
RotateCw,
Maximize2,
Minimize2,
ChevronDown,
Magnet,
AlertTriangle,
SunDim,
Layers,
Box,
View,
Sidebar
} from 'lucide-react';
import { Core, Entity } from '@esengine/ecs-framework';
import { MessageHub, ProjectService, IFileSystemService, type IFileSystem, IDialogService, type IDialog } from '@esengine/editor-core';
import { TilemapComponent, type ITilesetData, type ResizeAnchor } from '@esengine/tilemap';
import { useTilemapEditorStore, type TilemapToolType, type LayerState } from '../../stores/TilemapEditorStore';
import { TilemapCanvas } from '../TilemapCanvas';
import { TileSetSelectorPanel } from './TileSetSelectorPanel';
import { TilemapDetailsPanel } from './TilemapDetailsPanel';
import '../../styles/TilemapEditor.css';
// Asset Picker Dialog component
interface FileNode {
name: string;
path: string;
isDirectory: boolean;
children?: FileNode[];
}
interface AssetPickerDialogProps {
isOpen: boolean;
onClose: () => void;
onSelect: (path: string) => void;
title?: string;
fileExtensions?: string[];
}
function AssetPickerDialog({
isOpen,
onClose,
onSelect,
title = '选择资产',
fileExtensions = []
}: AssetPickerDialogProps) {
const [searchTerm, setSearchTerm] = useState('');
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [assets, setAssets] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(false);
const [previewPath, setPreviewPath] = useState<string | null>(null);
const [previewPosition, setPreviewPosition] = useState({ x: 0, y: 0 });
const [previewSrc, setPreviewSrc] = useState<string | null>(null);
const isImageFile = (name: string) => {
const ext = name.split('.').pop()?.toLowerCase();
return ['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(ext || '');
};
const handleMouseEnter = (e: React.MouseEvent, node: FileNode) => {
if (!node.isDirectory && isImageFile(node.name)) {
const rect = e.currentTarget.getBoundingClientRect();
setPreviewPosition({ x: rect.right + 10, y: rect.top - 50 });
setPreviewPath(node.path);
const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
if (fileSystem) {
const assetUrl = fileSystem.convertToAssetUrl(node.path);
setPreviewSrc(assetUrl);
}
}
};
const handleMouseLeave = () => {
setPreviewPath(null);
setPreviewSrc(null);
};
useEffect(() => {
if (!isOpen) return;
const loadAssets = async () => {
setLoading(true);
try {
const projectService = Core.services.tryResolve(ProjectService);
const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
const currentProject = projectService?.getCurrentProject();
if (projectService && currentProject && fileSystem) {
const projectPath = currentProject.path.replace(/\//g, '\\');
const assetsPath = `${projectPath}\\assets`;
const buildTree = async (dirPath: string): Promise<FileNode[]> => {
const entries = await fileSystem.listDirectory(dirPath);
const nodes: FileNode[] = [];
for (const entry of entries) {
const node: FileNode = {
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory
};
if (entry.isDirectory) {
try {
node.children = await buildTree(entry.path);
} catch {
node.children = [];
}
}
nodes.push(node);
}
return nodes.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
});
};
const tree = await buildTree(assetsPath);
setAssets(tree);
}
} catch (error) {
console.error('Failed to load assets:', error);
} finally {
setLoading(false);
}
};
loadAssets();
setSelectedPath(null);
setSearchTerm('');
}, [isOpen]);
const filterNode = useCallback((node: FileNode): FileNode | null => {
if (!node.isDirectory && fileExtensions.length > 0) {
const hasValidExtension = fileExtensions.some((ext) =>
node.name.toLowerCase().endsWith(ext.toLowerCase())
);
if (!hasValidExtension) return null;
}
const matchesSearch = !searchTerm ||
node.name.toLowerCase().includes(searchTerm.toLowerCase());
if (node.isDirectory && node.children) {
const filteredChildren = node.children
.map(filterNode)
.filter((n): n is FileNode => n !== null);
if (filteredChildren.length > 0 || matchesSearch) {
return { ...node, children: filteredChildren };
}
return null;
}
return matchesSearch ? node : null;
}, [searchTerm, fileExtensions]);
const filteredAssets = assets
.map(filterNode)
.filter((n): n is FileNode => n !== null);
const toggleFolder = useCallback((path: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const handleSelect = useCallback((node: FileNode) => {
if (node.isDirectory) {
toggleFolder(node.path);
} else {
setSelectedPath(node.path);
}
}, [toggleFolder]);
const toRelativePath = useCallback((absolutePath: string): string => {
const projectService = Core.services.tryResolve(ProjectService);
const currentProject = projectService?.getCurrentProject();
if (currentProject) {
const projectPath = currentProject.path.replace(/\\/g, '/');
const normalizedAbsolute = absolutePath.replace(/\\/g, '/');
if (normalizedAbsolute.startsWith(projectPath)) {
return normalizedAbsolute.substring(projectPath.length + 1);
}
}
return absolutePath;
}, []);
const handleConfirm = useCallback(() => {
if (selectedPath) {
onSelect(toRelativePath(selectedPath));
onClose();
}
}, [selectedPath, onSelect, onClose, toRelativePath]);
const handleDoubleClick = useCallback((node: FileNode) => {
if (!node.isDirectory) {
onSelect(toRelativePath(node.path));
onClose();
}
}, [onSelect, onClose, toRelativePath]);
const getFileIcon = (name: string) => {
const ext = name.split('.').pop()?.toLowerCase();
switch (ext) {
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
case 'webp':
return <ImageIcon size={14} />;
default:
return <File size={14} />;
}
};
const renderNode = (node: FileNode, depth: number = 0) => {
const isExpanded = expandedFolders.has(node.path);
const isSelected = selectedPath === node.path;
return (
<div key={node.path}>
<div
className={`asset-picker-item ${isSelected ? 'selected' : ''}`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => handleSelect(node)}
onDoubleClick={() => handleDoubleClick(node)}
onMouseEnter={(e) => handleMouseEnter(e, node)}
onMouseLeave={handleMouseLeave}
>
<span className="asset-picker-item-icon">
{node.isDirectory ? (
isExpanded ? <FolderOpen size={14} /> : <Folder size={14} />
) : (
getFileIcon(node.name)
)}
</span>
<span className="asset-picker-item-name">{node.name}</span>
</div>
{node.isDirectory && isExpanded && node.children && (
<div>
{node.children.map((child) => renderNode(child, depth + 1))}
</div>
)}
</div>
);
};
if (!isOpen) return null;
return (
<div className="asset-picker-overlay" onClick={onClose}>
<div className="asset-picker-dialog" onClick={(e) => e.stopPropagation()}>
<div className="asset-picker-header">
<h3>{title}</h3>
<button className="asset-picker-close" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="asset-picker-search">
<Search size={14} />
<input
type="text"
placeholder="搜索资产..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
autoFocus
/>
</div>
<div className="asset-picker-content">
{loading ? (
<div className="asset-picker-loading">...</div>
) : filteredAssets.length === 0 ? (
<div className="asset-picker-empty"></div>
) : (
<div className="asset-picker-tree">
{filteredAssets.map((node) => renderNode(node))}
</div>
)}
</div>
<div className="asset-picker-footer">
<div className="asset-picker-selected">
{selectedPath ? (
<span title={selectedPath}>
{selectedPath.split(/[\\/]/).pop()}
</span>
) : (
<span className="placeholder"></span>
)}
</div>
<div className="asset-picker-actions">
<button className="btn-cancel" onClick={onClose}>
</button>
<button
className="btn-confirm"
onClick={handleConfirm}
disabled={!selectedPath}
>
</button>
</div>
</div>
{previewPath && previewSrc && (
<div
className="asset-picker-preview"
style={{
left: previewPosition.x,
top: previewPosition.y
}}
>
<img
src={previewSrc}
alt="Preview"
style={{ maxWidth: '200px', maxHeight: '200px', objectFit: 'contain' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
</div>
</div>
);
}
// Resize Map Dialog component
interface ResizeMapDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (width: number, height: number, anchor: ResizeAnchor) => void;
currentWidth: number;
currentHeight: number;
}
function ResizeMapDialog({
isOpen,
onClose,
onConfirm,
currentWidth,
currentHeight
}: ResizeMapDialogProps) {
const [newWidth, setNewWidth] = useState(currentWidth);
const [newHeight, setNewHeight] = useState(currentHeight);
const [anchor, setAnchor] = useState<ResizeAnchor>('bottom-left');
useEffect(() => {
if (isOpen) {
setNewWidth(currentWidth);
setNewHeight(currentHeight);
}
}, [isOpen, currentWidth, currentHeight]);
if (!isOpen) return null;
const anchorPositions: ResizeAnchor[] = [
'top-left', 'top-center', 'top-right',
'middle-left', 'center', 'middle-right',
'bottom-left', 'bottom-center', 'bottom-right'
];
const handleConfirm = () => {
if (newWidth > 0 && newHeight > 0) {
onConfirm(newWidth, newHeight, anchor);
onClose();
}
};
return (
<div className="asset-picker-overlay" onClick={onClose}>
<div className="asset-picker-dialog resize-dialog" onClick={(e) => e.stopPropagation()} style={{ width: '320px' }}>
<div className="asset-picker-header">
<h3></h3>
<button className="asset-picker-close" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="resize-dialog-content" style={{ padding: '16px' }}>
<div style={{ display: 'flex', gap: '16px', marginBottom: '16px' }}>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px', color: '#888' }}>
()
</label>
<input
type="number"
value={newWidth}
onChange={(e) => setNewWidth(Math.max(1, parseInt(e.target.value) || 1))}
min={1}
style={{
width: '100%',
padding: '8px',
backgroundColor: '#1e1e1e',
border: '1px solid #444',
borderRadius: '4px',
color: '#e0e0e0'
}}
/>
</div>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px', color: '#888' }}>
()
</label>
<input
type="number"
value={newHeight}
onChange={(e) => setNewHeight(Math.max(1, parseInt(e.target.value) || 1))}
min={1}
style={{
width: '100%',
padding: '8px',
backgroundColor: '#1e1e1e',
border: '1px solid #444',
borderRadius: '4px',
color: '#e0e0e0'
}}
/>
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '12px', color: '#888' }}>
</label>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '4px',
width: '120px',
margin: '0 auto'
}}>
{anchorPositions.map((pos) => (
<button
key={pos}
onClick={() => setAnchor(pos)}
style={{
width: '36px',
height: '36px',
backgroundColor: anchor === pos ? '#0078d4' : '#2a2a2a',
border: '1px solid #444',
borderRadius: '4px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title={pos}
>
<div style={{
width: '8px',
height: '8px',
backgroundColor: anchor === pos ? '#fff' : '#666',
borderRadius: '50%'
}} />
</button>
))}
</div>
</div>
</div>
<div className="asset-picker-footer">
<div className="asset-picker-actions">
<button className="btn-cancel" onClick={onClose}>
</button>
<button
className="btn-confirm"
onClick={handleConfirm}
disabled={newWidth === currentWidth && newHeight === currentHeight}
>
</button>
</div>
</div>
</div>
</div>
);
}
// Helper to convert file path to URL
function convertFileSrc(path: string): string {
const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
if (fileSystem) {
return fileSystem.convertToAssetUrl(path);
}
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
return path;
}
interface TilemapEditorPanelProps {
projectPath?: string | null;
messageHub?: MessageHub;
}
// Resizable Panel Divider Component
interface PanelDividerProps {
onDrag: (delta: number) => void;
direction: 'horizontal' | 'vertical';
}
const PanelDivider: React.FC<PanelDividerProps> = ({ onDrag, direction }) => {
const isDraggingRef = useRef(false);
const lastPosRef = useRef(0);
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
isDraggingRef.current = true;
lastPosRef.current = direction === 'horizontal' ? e.clientX : e.clientY;
document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!isDraggingRef.current) return;
const currentPos = direction === 'horizontal' ? moveEvent.clientX : moveEvent.clientY;
const delta = currentPos - lastPosRef.current;
lastPosRef.current = currentPos;
onDrag(delta);
};
const handleMouseUp = () => {
isDraggingRef.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
return (
<div
className={`panel-divider ${direction}`}
onMouseDown={handleMouseDown}
/>
);
};
export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageHub: propMessageHub }) => {
const [tilemap, setTilemap] = useState<TilemapComponent | null>(null);
const [entity, setEntity] = useState<Entity | null>(null);
// Panel widths for resizable layout - smaller defaults to give viewport more space
const [leftPanelWidth, setLeftPanelWidth] = useState(180);
const [rightPanelWidth, setRightPanelWidth] = useState(220);
const handleLeftDividerDrag = useCallback((delta: number) => {
setLeftPanelWidth(prev => Math.max(120, Math.min(350, prev + delta)));
}, []);
const handleRightDividerDrag = useCallback((delta: number) => {
setRightPanelWidth(prev => Math.max(180, Math.min(400, prev - delta)));
}, []);
const [tilesetImage, setTilesetImage] = useState<HTMLImageElement | null>(null);
const [tilemapKey, setTilemapKey] = useState('');
const [showAssetPicker, setShowAssetPicker] = useState(false);
const [showResizeDialog, setShowResizeDialog] = useState(false);
const [activeTilesetIndex, setActiveTilesetIndex] = useState(0);
// Viewport state
const [viewMode, setViewMode] = useState<'right' | 'left' | 'top' | 'bottom'>('right');
const [litMode, setLitMode] = useState(true);
const [showViewOptions, setShowViewOptions] = useState(false);
const [transformMode, setTransformMode] = useState<'select' | 'move' | 'rotate' | 'scale'>('select');
const messageHub = propMessageHub || Core.services.resolve(MessageHub);
const {
entityId,
pendingFilePath,
currentFilePath,
currentTool,
zoom,
showGrid,
showCollision,
editingCollision,
tileWidth,
tileHeight,
tilesetImageUrl,
tilesetColumns,
tilesetRows,
setEntityId,
setPendingFilePath,
setCurrentFilePath,
setCurrentTool,
setZoom,
setShowGrid,
setShowCollision,
setEditingCollision,
setPan,
setTileset,
setLayers,
setCurrentLayer
} = useTilemapEditorStore();
// Load tileset from component (defined early for use in effects)
const loadTilesetFromComponent = useCallback((tilemapComp: TilemapComponent) => {
const tilesetRef = tilemapComp.tilesets[activeTilesetIndex];
if (!tilesetRef) {
setTileset(null, 0, 0, tilemapComp.tileWidth, tilemapComp.tileHeight);
return;
}
const tilesetPath = tilesetRef.source;
const projectService = Core.services.tryResolve(ProjectService);
const currentProject = projectService?.getCurrentProject();
let absolutePath = tilesetPath;
if (currentProject && !tilesetPath.startsWith('/') && !tilesetPath.match(/^[a-zA-Z]:/)) {
const projectPath = currentProject.path.replace(/\\/g, '/');
absolutePath = `${projectPath}/${tilesetPath}`.replace(/\\/g, '/');
}
const imageUrl = convertFileSrc(absolutePath);
if (tilesetRef.data) {
const tilesetData = tilesetRef.data;
setTileset(imageUrl, tilesetData.columns, tilesetData.rows, tilesetData.tileWidth, tilesetData.tileHeight);
} else {
const img = new Image();
img.onload = () => {
const columns = Math.floor(img.width / tilemapComp.tileWidth);
const rows = Math.floor(img.height / tilemapComp.tileHeight);
const tilesetData: ITilesetData = {
name: 'tileset',
version: 1,
image: tilesetPath,
imageWidth: img.width,
imageHeight: img.height,
tileWidth: tilemapComp.tileWidth,
tileHeight: tilemapComp.tileHeight,
tileCount: columns * rows,
columns,
rows
};
tilemapComp.setTilesetData(activeTilesetIndex, tilesetData);
setTileset(imageUrl, columns, rows, tilemapComp.tileWidth, tilemapComp.tileHeight);
};
img.onerror = () => {
setTileset(null, 0, 0, tilemapComp.tileWidth, tilemapComp.tileHeight);
};
img.src = imageUrl;
}
}, [activeTilesetIndex, setTileset]);
// Load file from pendingFilePath on mount (for file-based editing via double-click)
useEffect(() => {
if (!pendingFilePath) return;
const loadTilemapFile = async () => {
try {
const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
if (!fileSystem) {
console.error('[TilemapEditorPanel] FileSystem service not available');
return;
}
// Clear entity-based editing state
setEntityId('');
setEntity(null);
// Load tilemap data from file
const content = await fileSystem.readFile(pendingFilePath);
const tilemapData = JSON.parse(content);
// Create a standalone TilemapComponent
const tilemapComp = new TilemapComponent();
tilemapComp.applyTilemapData(tilemapData);
tilemapComp.tilemapAssetGuid = pendingFilePath;
setCurrentFilePath(pendingFilePath);
setTilemap(tilemapComp);
// Load tileset
loadTilesetFromComponent(tilemapComp);
// Set up layers
const layerStates: LayerState[] = tilemapComp.layers.map((layer) => ({
id: layer.id,
name: layer.name,
visible: layer.visible,
locked: false,
opacity: layer.opacity
}));
setLayers(layerStates);
setCurrentLayer(0);
setTilemapKey(`file-${Date.now()}`);
// Clear pending file after loading
setPendingFilePath(null);
console.log('[TilemapEditorPanel] Loaded tilemap from file:', pendingFilePath);
} catch (error) {
console.error('[TilemapEditorPanel] Failed to load tilemap file:', error);
setPendingFilePath(null);
messageHub?.publish('notification:show', {
type: 'error',
message: `Failed to load tilemap: ${error instanceof Error ? error.message : String(error)}`,
duration: 3000
});
}
};
loadTilemapFile();
}, [pendingFilePath, setEntityId, setCurrentFilePath, setPendingFilePath, setLayers, setCurrentLayer, loadTilesetFromComponent, messageHub]);
// Listen for tilemap edit requests (entity-based)
useEffect(() => {
if (!messageHub) return;
const unsubscribe = messageHub.subscribe('tilemap:edit', (data: { entityId: string }) => {
// Clear file-based editing state when switching to entity mode
setCurrentFilePath(null);
setEntityId(data.entityId);
});
return unsubscribe;
}, [messageHub, setEntityId, setCurrentFilePath]);
// Load tilemap component when entityId changes
useEffect(() => {
if (!entityId) {
// Don't clear tilemap if we're in file-based editing mode
if (!currentFilePath) {
setTilemap(null);
setEntity(null);
}
return;
}
const scene = Core.scene;
if (!scene) return;
const foundEntity = scene.findEntityById(parseInt(entityId, 10));
if (!foundEntity) return;
const tilemapComp = foundEntity.getComponent(TilemapComponent);
if (!tilemapComp) return;
setEntity(foundEntity);
setTilemap(tilemapComp);
loadTilesetFromComponent(tilemapComp);
const layerStates: LayerState[] = tilemapComp.layers.map((layer) => ({
id: layer.id,
name: layer.name,
visible: layer.visible,
locked: false,
opacity: layer.opacity
}));
setLayers(layerStates);
setCurrentLayer(0);
}, [entityId, currentFilePath, loadTilesetFromComponent, setLayers, setCurrentLayer]);
// Listen for scene modifications
useEffect(() => {
if (!messageHub || !tilemap) return;
const unsubscribeModified = messageHub.subscribe('scene:modified', () => {
loadTilesetFromComponent(tilemap);
setTilemapKey(`${tilemap.width}-${tilemap.height}-${Date.now()}`);
});
const unsubscribeRestored = messageHub.subscribe('scene:restored', () => {
if (!entityId) return;
const scene = Core.scene;
if (!scene) return;
const foundEntity = scene.findEntityById(parseInt(entityId, 10));
if (!foundEntity) return;
const newTilemap = foundEntity.getComponent(TilemapComponent);
if (!newTilemap) return;
setTilemap(newTilemap);
loadTilesetFromComponent(newTilemap);
});
return () => {
unsubscribeModified();
unsubscribeRestored();
};
}, [messageHub, tilemap, entityId, loadTilesetFromComponent]);
// Load tileset image
useEffect(() => {
if (!tilesetImageUrl) {
setTilesetImage(null);
return;
}
const img = new Image();
img.onload = () => setTilesetImage(img);
img.src = tilesetImageUrl;
}, [tilesetImageUrl]);
const handleTilemapChange = useCallback(() => {
messageHub?.publish('scene:modified', {});
}, [messageHub]);
const handleSaveTilemap = useCallback(async () => {
if (!tilemap || !entity) return;
try {
const tilemapData = tilemap.exportToData();
const jsonContent = JSON.stringify(tilemapData, null, 2);
const tilemapAssetPath = tilemap.tilemapAssetGuid;
if (!tilemapAssetPath) {
console.warn('Tilemap asset path not set');
return;
}
const projectService = Core.services.tryResolve(ProjectService);
const currentProject = projectService?.getCurrentProject();
if (!currentProject) return;
const normalizedAssetPath = tilemapAssetPath.replace(/\\/g, '/');
const normalizedProjectPath = currentProject.path.replace(/\\/g, '/');
let absolutePath: string;
if (normalizedAssetPath.match(/^[a-zA-Z]:/) || normalizedAssetPath.startsWith('/')) {
absolutePath = normalizedAssetPath;
} else {
absolutePath = `${normalizedProjectPath}/${normalizedAssetPath}`;
}
const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
if (fileSystem) {
await fileSystem.writeFile(absolutePath, jsonContent);
messageHub?.publish('notification:show', {
type: 'success',
message: 'Tilemap saved',
duration: 2000
});
}
} catch (error) {
console.error('Failed to save tilemap:', error);
messageHub?.publish('notification:show', {
type: 'error',
message: `Save failed: ${error instanceof Error ? error.message : String(error)}`,
duration: 3000
});
}
}, [tilemap, entity, messageHub]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
e.stopPropagation();
handleSaveTilemap();
}
};
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
}, [handleSaveTilemap]);
const handleZoomIn = () => setZoom(Math.min(10, zoom * 1.2));
const handleZoomOut = () => setZoom(Math.max(0.1, zoom / 1.2));
const handleResetView = () => {
setZoom(1);
setPan(0, 0);
};
// 退出全屏模式
const handleExitFullscreen = useCallback(() => {
messageHub?.publish('editor:fullscreen', { fullscreen: false });
}, [messageHub]);
// Layer operations
const handleAddLayer = useCallback(() => {
if (!tilemap) return;
tilemap.addLayer(`Layer ${tilemap.layers.length + 1}`);
const layerStates: LayerState[] = tilemap.layers.map((layer) => ({
id: layer.id,
name: layer.name,
visible: layer.visible,
locked: false,
opacity: layer.opacity
}));
setLayers(layerStates);
setCurrentLayer(tilemap.layers.length - 1);
tilemap.renderDirty = true;
handleTilemapChange();
}, [tilemap, setLayers, setCurrentLayer, handleTilemapChange]);
const handleRemoveLayer = useCallback((index: number) => {
if (!tilemap || tilemap.layers.length <= 1) return;
tilemap.removeLayer(index);
const layerStates: LayerState[] = tilemap.layers.map((layer) => ({
id: layer.id,
name: layer.name,
visible: layer.visible,
locked: false,
opacity: layer.opacity
}));
setLayers(layerStates);
const { currentLayer } = useTilemapEditorStore.getState();
if (currentLayer >= tilemap.layers.length) {
setCurrentLayer(tilemap.layers.length - 1);
}
tilemap.renderDirty = true;
handleTilemapChange();
}, [tilemap, setLayers, setCurrentLayer, handleTilemapChange]);
const handleMoveLayer = useCallback((fromIndex: number, toIndex: number) => {
if (!tilemap) return;
if (toIndex < 0 || toIndex >= tilemap.layers.length) return;
tilemap.moveLayer(fromIndex, toIndex);
const layerStates: LayerState[] = tilemap.layers.map((layer) => ({
id: layer.id,
name: layer.name,
visible: layer.visible,
locked: false,
opacity: layer.opacity
}));
setLayers(layerStates);
setCurrentLayer(toIndex);
tilemap.renderDirty = true;
handleTilemapChange();
}, [tilemap, setLayers, setCurrentLayer, handleTilemapChange]);
// Tileset operations
const handleAddTileset = useCallback(() => {
if (!tilemap) return;
setShowAssetPicker(true);
}, [tilemap]);
const handleTilesetSelected = useCallback((path: string) => {
if (!tilemap) return;
tilemap.addTileset(path);
setActiveTilesetIndex(tilemap.tilesets.length - 1);
loadTilesetFromComponent(tilemap);
handleTilemapChange();
}, [tilemap, loadTilesetFromComponent, handleTilemapChange]);
const handleTilesetChange = useCallback((index: number) => {
setActiveTilesetIndex(index);
if (tilemap) {
loadTilesetFromComponent(tilemap);
}
}, [tilemap, loadTilesetFromComponent]);
// Resize map
const handleResizeMap = useCallback((newWidth: number, newHeight: number, anchor: ResizeAnchor) => {
if (!tilemap) return;
tilemap.resize(newWidth, newHeight, anchor);
setTilemapKey(`${newWidth}-${newHeight}-${Date.now()}`);
handleTilemapChange();
}, [tilemap, handleTilemapChange]);
// Get tileset list
const tilesetOptions = tilemap?.tilesets.map((t, i) => ({
name: t.data?.name || `Tileset ${i + 1}`,
path: t.source
})) || [];
// Empty state
if (!tilemap) {
return (
<div className="tilemap-editor-panel">
<div className="tilemap-editor-empty">
<Map size={48} />
<h3></h3>
<p>
TilemapComponent
<br />
"编辑瓦片地图"
</p>
</div>
</div>
);
}
return (
<div className="tilemap-editor-panel">
{/* Left Panel - Tile Set Selector */}
<div style={{ width: leftPanelWidth, flexShrink: 0 }}>
<TileSetSelectorPanel
tilesets={tilesetOptions}
activeTilesetIndex={activeTilesetIndex}
onTilesetChange={handleTilesetChange}
onAddTileset={handleAddTileset}
/>
</div>
{/* Left Divider */}
<PanelDivider direction="horizontal" onDrag={handleLeftDividerDrag} />
{/* Center - Viewport */}
<div className="tilemap-viewport">
{/* Viewport top toolbar */}
<div className="viewport-toolbar">
<div className="viewport-toolbar-left">
{/* View mode buttons */}
<div className="viewport-btn-group">
<button
className={`viewport-btn icon ${viewMode === 'right' ? 'active' : ''}`}
onClick={() => setViewMode('right')}
title="右视图"
>
<Box size={14} />
</button>
<button
className={`viewport-btn icon ${litMode ? 'active' : ''}`}
onClick={() => setLitMode(!litMode)}
title="光照模式"
>
<SunDim size={14} />
</button>
<button
className="viewport-btn icon"
onClick={() => setShowViewOptions(!showViewOptions)}
title="显示选项"
>
<Layers size={14} />
<ChevronDown size={10} />
</button>
</div>
</div>
<div className="viewport-toolbar-center">
{/* Transform tools */}
<div className="viewport-btn-group">
<button
className={`viewport-btn icon ${transformMode === 'select' ? 'active' : ''}`}
onClick={() => setTransformMode('select')}
title="选择"
>
<MousePointer2 size={14} />
</button>
<button
className={`viewport-btn icon ${transformMode === 'move' ? 'active' : ''}`}
onClick={() => setTransformMode('move')}
title="移动"
>
<Move size={14} />
</button>
<button
className={`viewport-btn icon ${transformMode === 'rotate' ? 'active' : ''}`}
onClick={() => setTransformMode('rotate')}
title="旋转"
>
<RotateCw size={14} />
</button>
<button
className={`viewport-btn icon ${transformMode === 'scale' ? 'active' : ''}`}
onClick={() => setTransformMode('scale')}
title="缩放"
>
<Maximize2 size={14} />
</button>
</div>
<div className="viewport-separator" />
{/* Grid/snap controls */}
<div className="viewport-btn-group">
<button
className={`viewport-btn icon ${showGrid ? 'active' : ''}`}
onClick={() => setShowGrid(!showGrid)}
title="切换网格"
>
<Grid3x3 size={14} />
</button>
<button className="viewport-btn snap-btn" title="位置吸附">
<Magnet size={12} />
10
</button>
<button className="viewport-btn snap-btn" title="旋转吸附">
<RotateCw size={12} />
10°
</button>
<button className="viewport-btn snap-btn" title="缩放吸附">
<Scaling size={12} />
0.25
</button>
</div>
</div>
<div className="viewport-toolbar-right">
{/* Zoom controls */}
<div className="viewport-btn-group">
<button className="viewport-btn icon" onClick={handleZoomOut} title="缩小">
<ZoomOut size={14} />
</button>
<span className="zoom-display">{Math.round(zoom * 100)}%</span>
<button className="viewport-btn icon" onClick={handleZoomIn} title="放大">
<ZoomIn size={14} />
</button>
<button className="viewport-btn icon" onClick={handleResetView} title="重置视图">
<RotateCcw size={14} />
</button>
</div>
</div>
</div>
{/* Info overlay */}
<div className="viewport-info-overlay">
<div className="info-item">
<strong> ()</strong>
</div>
<div className="info-item warning">
<AlertTriangle size={12} />
警告: 碰撞已启用但没有形状
</div>
<div className="info-item">
<strong> ()</strong>
</div>
<div className="info-item">区段: 0</div>
<div className="info-item">三角形: 0 ()</div>
<div className="info-item">: {tilemap.width * tilemap.tileWidth}x{tilemap.height * tilemap.tileHeight}</div>
</div>
{/* Canvas */}
<div className="viewport-canvas-container">
<TilemapCanvas
key={tilemapKey}
tilemap={tilemap}
tilesetImage={tilesetImage}
onTilemapChange={handleTilemapChange}
/>
</div>
{/* Scale ruler - width represents 100 pixels at current zoom */}
<div className="viewport-ruler">
<div className="ruler-marker">
<div className="ruler-line" style={{ width: 100 * zoom }} />
<span>{(100 / zoom / tilemap.tileWidth).toFixed(1)} </span>
</div>
</div>
{/* Beta preview watermark */}
<div className="viewport-watermark"></div>
</div>
{/* Right Divider */}
<PanelDivider direction="horizontal" onDrag={handleRightDividerDrag} />
{/* Right Panel - Details */}
<div style={{ width: rightPanelWidth, flexShrink: 0 }}>
<TilemapDetailsPanel
tilemap={tilemap}
onAddLayer={handleAddLayer}
onRemoveLayer={handleRemoveLayer}
onMoveLayer={handleMoveLayer}
onTilemapChange={handleTilemapChange}
onOpenAssetPicker={() => setShowAssetPicker(true)}
/>
</div>
{/* Dialogs */}
<AssetPickerDialog
isOpen={showAssetPicker}
onClose={() => setShowAssetPicker(false)}
onSelect={handleTilesetSelected}
title="选择瓦片集图片"
fileExtensions={['.png', '.jpg', '.jpeg', '.webp']}
/>
<ResizeMapDialog
isOpen={showResizeDialog}
onClose={() => setShowResizeDialog(false)}
onConfirm={handleResizeMap}
currentWidth={tilemap.width}
currentHeight={tilemap.height}
/>
</div>
);
};