/** * Tilemap Editor Panel - Main editing panel */ import React, { useEffect, useState, useRef, useCallback } from 'react'; import { Paintbrush, Eraser, PaintBucket, Grid3x3, Eye, EyeOff, ZoomIn, ZoomOut, RotateCcw, Map, Shield, Plus, Trash2, ChevronDown, ChevronRight, PanelRightClose, PanelRightOpen, X, Search, Folder, FolderOpen, File, Image as ImageIcon, Save, Scaling } from 'lucide-react'; import { Core, Entity } from '@esengine/ecs-framework'; import { MessageHub, ProjectService, IFileSystemService, type IFileSystem } 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 { TilesetPreview } from '../TilesetPreview'; import { LayerPanel } from './LayerPanel'; 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 = 'Select Asset', fileExtensions = [] }: AssetPickerDialogProps) { const [searchTerm, setSearchTerm] = useState(''); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [selectedPath, setSelectedPath] = useState(null); const [assets, setAssets] = useState([]); const [loading, setLoading] = useState(false); const [previewPath, setPreviewPath] = useState(null); const [previewPosition, setPreviewPosition] = useState({ x: 0, y: 0 }); const [previewSrc, setPreviewSrc] = useState(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); // Use FileSystem service to convert local path to asset URL 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 => { 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 ; default: return ; } }; const renderNode = (node: FileNode, depth: number = 0) => { const isExpanded = expandedFolders.has(node.path); const isSelected = selectedPath === node.path; return (
handleSelect(node)} onDoubleClick={() => handleDoubleClick(node)} onMouseEnter={(e) => handleMouseEnter(e, node)} onMouseLeave={handleMouseLeave} > {node.isDirectory ? ( isExpanded ? : ) : ( getFileIcon(node.name) )} {node.name}
{node.isDirectory && isExpanded && node.children && (
{node.children.map((child) => renderNode(child, depth + 1))}
)}
); }; if (!isOpen) return null; return (
e.stopPropagation()}>

{title}

setSearchTerm(e.target.value)} autoFocus />
{loading ? (
Loading assets...
) : filteredAssets.length === 0 ? (
No assets found
) : (
{filteredAssets.map((node) => renderNode(node))}
)}
{selectedPath ? ( {selectedPath.split(/[\\/]/).pop()} ) : ( No asset selected )}
{/* Image Preview Tooltip */} {previewPath && previewSrc && (
Preview { console.error('Preview image load error:', previewPath); (e.target as HTMLImageElement).style.display = 'none'; }} />
)}
); } // 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('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 (
e.stopPropagation()} style={{ width: '320px' }}>

调整地图大小

{/* Size inputs */}
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' }} />
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' }} />
{/* Anchor selector */}
{anchorPositions.map((pos) => ( ))}
{/* Preview */}
当前: {currentWidth} × {currentHeight} → 新: {newWidth} × {newHeight}
{newWidth > currentWidth && `右侧增加 ${newWidth - currentWidth} 列`} {newWidth < currentWidth && `右侧减少 ${currentWidth - newWidth} 列`} {newWidth !== currentWidth && newHeight !== currentHeight && ' | '} {newHeight > currentHeight && `顶部增加 ${newHeight - currentHeight} 行`} {newHeight < currentHeight && `顶部减少 ${currentHeight - newHeight} 行`} {newWidth === currentWidth && newHeight === currentHeight && '无变化'}
); } // Helper to convert file path to URL using FileSystem service function convertFileSrc(path: string): string { // Use FileSystem service to convert local path to asset URL const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null; if (fileSystem) { return fileSystem.convertToAssetUrl(path); } // Fallback for already-converted URLs if (path.startsWith('http://') || path.startsWith('https://')) { return path; } return path; } interface TilemapEditorPanelProps { projectPath?: string | null; messageHub?: MessageHub; } export const TilemapEditorPanel: React.FC = ({ messageHub: propMessageHub }) => { const [tilemap, setTilemap] = useState(null); const [entity, setEntity] = useState(null); const [tilesetImage, setTilesetImage] = useState(null); const [tilemapKey, setTilemapKey] = useState(''); const [showTilesetPanel, setShowTilesetPanel] = useState(true); const [showSidebar, setShowSidebar] = useState(true); const [tilesetHeight, setTilesetHeight] = useState(200); const [sidebarWidth, setSidebarWidth] = useState(220); const [isResizing, setIsResizing] = useState(false); const [isResizingWidth, setIsResizingWidth] = useState(false); const [showAssetPicker, setShowAssetPicker] = useState(false); const [showResizeDialog, setShowResizeDialog] = useState(false); const sidebarRef = useRef(null); const messageHub = propMessageHub || Core.services.resolve(MessageHub); const { entityId, currentTool, zoom, showGrid, showCollision, editingCollision, tileWidth, tileHeight, tilesetImageUrl, tilesetColumns, tilesetRows, selectedTiles, setEntityId, setCurrentTool, setZoom, setShowGrid, setShowCollision, setEditingCollision, setPan, setTileset, setLayers, setCurrentLayer } = useTilemapEditorStore(); // Listen for tilemap edit requests useEffect(() => { if (!messageHub) return; const unsubscribe = messageHub.subscribe('tilemap:edit', (data: { entityId: string }) => { setEntityId(data.entityId); }); return unsubscribe; }, [messageHub, setEntityId]); // Helper to load tileset from component const loadTilesetFromComponent = (tilemapComp: TilemapComponent) => { // Get tileset source from first tileset const tilesetRef = tilemapComp.tilesets[0]; if (!tilesetRef) { setTileset(null, 0, 0, tilemapComp.tileWidth, tilemapComp.tileHeight); return; } const tilesetPath = tilesetRef.source; // Convert relative path to absolute path const projectService = Core.services.tryResolve(ProjectService); const currentProject = projectService?.getCurrentProject(); let absolutePath = tilesetPath; if (currentProject && !tilesetPath.startsWith('/') && !tilesetPath.match(/^[a-zA-Z]:/)) { // It's a relative path, convert to absolute const projectPath = currentProject.path.replace(/\\/g, '/'); absolutePath = `${projectPath}/${tilesetPath}`.replace(/\\/g, '/'); } const imageUrl = convertFileSrc(absolutePath); // Use existing tileset data if available, otherwise load from image if (tilesetRef.data) { const tilesetData = tilesetRef.data; console.log('[TilemapEditor] Using saved tileset data:', { columns: tilesetData.columns, rows: tilesetData.rows, tileWidth: tilesetData.tileWidth, tileHeight: tilesetData.tileHeight, imageWidth: tilesetData.imageWidth, imageHeight: tilesetData.imageHeight }); setTileset(imageUrl, tilesetData.columns, tilesetData.rows, tilesetData.tileWidth, tilesetData.tileHeight); } else { // Fallback: calculate from image dimensions const img = new Image(); img.onload = () => { const columns = Math.floor(img.width / tilemapComp.tileWidth); const rows = Math.floor(img.height / tilemapComp.tileHeight); // Create tileset data and set it 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(0, tilesetData); setTileset(imageUrl, columns, rows, tilemapComp.tileWidth, tilemapComp.tileHeight); }; img.onerror = () => { setTileset(null, 0, 0, tilemapComp.tileWidth, tilemapComp.tileHeight); }; img.src = imageUrl; } }; // Load tilemap component when entityId changes useEffect(() => { if (!entityId) { 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); // Sync layers to store 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, setTileset, 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, tilesetImageUrl, setTileset, entityId]); // Load tileset image useEffect(() => { if (!tilesetImageUrl) { setTilesetImage(null); return; } const img = new Image(); img.onload = () => setTilesetImage(img); img.src = tilesetImageUrl; }, [tilesetImageUrl]); const handleTilemapChange = () => { messageHub?.publish('scene:modified', {}); }; const handleSaveTilemap = useCallback(async () => { if (!tilemap || !entity) return; try { // Export tilemap data const tilemapData = tilemap.exportToData(); const jsonContent = JSON.stringify(tilemapData, null, 2); // Get the tilemap asset path from component let tilemapAssetPath = tilemap.tilemapAssetGuid; if (!tilemapAssetPath) { console.warn('Tilemap asset path not set, cannot save'); return; } // Convert to absolute path if it's a relative path const projectService = Core.services.tryResolve(ProjectService); const currentProject = projectService?.getCurrentProject(); if (!currentProject) { console.warn('No project loaded, cannot save tilemap'); return; } // Normalize paths const normalizedAssetPath = tilemapAssetPath.replace(/\\/g, '/'); const normalizedProjectPath = currentProject.path.replace(/\\/g, '/'); // Check if path is already absolute (starts with drive letter or /) let absolutePath: string; if (normalizedAssetPath.match(/^[a-zA-Z]:/) || normalizedAssetPath.startsWith('/')) { // Already absolute path absolutePath = normalizedAssetPath; } else { // Relative path, combine with project path absolutePath = `${normalizedProjectPath}/${normalizedAssetPath}`; } // Save using FileSystem service const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null; if (fileSystem) { await fileSystem.writeFile(absolutePath, jsonContent); // Show success notification messageHub?.publish('notification:show', { type: 'success', message: 'Tilemap 保存成功', duration: 2000 }); } } catch (error) { console.error('Failed to save tilemap:', error); // Show error notification messageHub?.publish('notification:show', { type: 'error', message: `保存失败: ${error instanceof Error ? error.message : String(error)}`, duration: 3000 }); } }, [tilemap, entity]); // Handle keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Ctrl+S to save tilemap 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 handleToolChange = (tool: TilemapToolType) => { setCurrentTool(tool); }; 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); }; // Layer operations const handleAddLayer = () => { 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(); }; const handleRemoveLayer = (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(); }; const handleMoveLayer = (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(); }; // Tileset operations const handleAddTileset = () => { if (!tilemap) return; setShowAssetPicker(true); }; const handleTilesetSelected = (path: string) => { if (!tilemap) return; tilemap.addTileset(path); loadTilesetFromComponent(tilemap); handleTilemapChange(); }; // Handle resize const handleResizeStart = (e: React.MouseEvent) => { e.preventDefault(); setIsResizing(true); const startY = e.clientY; const startHeight = tilesetHeight; const handleMouseMove = (moveEvent: MouseEvent) => { const delta = moveEvent.clientY - startY; const newHeight = Math.max(100, Math.min(400, startHeight + delta)); setTilesetHeight(newHeight); }; const handleMouseUp = () => { setIsResizing(false); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; // Handle sidebar width resize const handleSidebarResizeStart = (e: React.MouseEvent) => { e.preventDefault(); setIsResizingWidth(true); const startX = e.clientX; const startWidth = sidebarWidth; const handleMouseMove = (moveEvent: MouseEvent) => { const delta = startX - moveEvent.clientX; // Reverse delta because we're dragging from right to left const newWidth = Math.max(180, Math.min(400, startWidth + delta)); setSidebarWidth(newWidth); }; const handleMouseUp = () => { setIsResizingWidth(false); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; const handleRemoveTileset = (index: number) => { if (!tilemap) return; tilemap.removeTileset(index); if (tilemap.tilesets.length === 0) { setTileset(null, 0, 0, tilemap.tileWidth, tilemap.tileHeight); } else { loadTilesetFromComponent(tilemap); } handleTilemapChange(); }; // Handle 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]); if (!tilemap) { return (

No Tilemap Selected

Select an entity with a TilemapComponent
and click "Edit Tilemap" to start editing.

); } return (
{/* Toolbar */}
{Math.round(zoom * 100)}%
{/* Main content area */}
{/* Canvas */} {/* Sidebar toggle button */} {/* Right sidebar with tileset and layers */} {showSidebar && (
{/* Sidebar width resize handle */}
{/* Tileset Section */}
setShowTilesetPanel(!showTilesetPanel)} > {showTilesetPanel ? : } Tileset
{tilemap.tilesets.length > 0 && ( )}
{showTilesetPanel && (
{tilesetImageUrl ? ( <> {selectedTiles && (
Selected: {selectedTiles.width}×{selectedTiles.height}
)} ) : (

No tileset

)}
)} {/* Resize handle */} {showTilesetPanel && (
)}
{/* Layer Panel */}
)}
{/* Info bar */}
Size: {tilemap.width}×{tilemap.height} Tile: {tileWidth}×{tileHeight} {entity && Entity: {entity.name}} {editingCollision && Editing Collision}
{/* Asset Picker Dialog */} setShowAssetPicker(false)} onSelect={handleTilesetSelected} title="Select Tileset Image" fileExtensions={['.png', '.jpg', '.jpeg', '.webp']} /> {/* Resize Map Dialog */} setShowResizeDialog(false)} onConfirm={handleResizeMap} currentWidth={tilemap.width} currentHeight={tilemap.height} />
); };