/** * Tilemap Details Panel - Right panel with grouped properties * Tilemap 详情面板 - 右侧分组属性面板 */ import React, { useState, useCallback, useRef, useEffect } from 'react'; import { ChevronDown, ChevronRight, Plus, ArrowUp, ArrowDown, Copy, X, Search, Settings, Eye, EyeOff, FileBox } from 'lucide-react'; import { useTilemapEditorStore, type LayerState } from '../../stores/TilemapEditorStore'; import type { TilemapComponent } from '@esengine/tilemap'; import '../../styles/TilemapDetailsPanel.css'; interface TilemapDetailsPanelProps { tilemap: TilemapComponent | null; onAddLayer: () => void; onRemoveLayer: (index: number) => void; onMoveLayer: (from: number, to: number) => void; onDuplicateLayer: (index: number) => void; onTilemapChange: () => void; onOpenAssetPicker: () => void; /** Callback to open material picker for a specific layer */ onSelectLayerMaterial?: (layerIndex: number) => void; } // Collapsible section component interface SectionProps { title: string; defaultOpen?: boolean; children: React.ReactNode; } const Section: React.FC = ({ title, defaultOpen = true, children }) => { const [isOpen, setIsOpen] = useState(defaultOpen); return (
setIsOpen(!isOpen)} > {isOpen ? : } {title}
{isOpen && (
{children}
)}
); }; // Property row component interface PropertyRowProps { label: string; children: React.ReactNode; indent?: boolean; } const PropertyRow: React.FC = ({ label, children, indent }) => (
{children}
); // Toggle property - unified style matching PropertyInspector interface TogglePropertyProps { label: string; checked: boolean; onChange: (checked: boolean) => void; indent?: boolean; } const ToggleProperty: React.FC = ({ label, checked, onChange, indent }) => (
); // Number input property interface NumberPropertyProps { label: string; value: number; onChange: (value: number) => void; min?: number; max?: number; step?: number; } const NumberProperty: React.FC = ({ label, value, onChange, min, max, step = 1 }) => ( onChange(parseFloat(e.target.value) || 0)} min={min} max={max} step={step} /> ); // Slider property for opacity etc. interface SliderPropertyProps { label: string; value: number; onChange: (value: number) => void; min?: number; max?: number; step?: number; } const SliderProperty: React.FC = ({ label, value, onChange, min = 0, max = 1, step = 0.01 }) => (
onChange(parseFloat(e.target.value))} min={min} max={max} step={step} /> {Math.round(value * 100)}%
); // Color property - unified style matching PropertyInspector interface ColorPropertyProps { label: string; value: string; onChange: (value: string) => void; showReset?: boolean; } const ColorProperty: React.FC = ({ label, value, onChange }) => { const inputRef = useRef(null); const handlePreviewClick = () => { inputRef.current?.click(); }; return (
onChange(e.target.value)} /> { const val = e.target.value; if (/^#[0-9A-Fa-f]{0,6}$/.test(val)) { onChange(val); } }} onBlur={(e) => { const val = e.target.value; if (!/^#[0-9A-Fa-f]{6}$/.test(val)) { onChange(value); } }} />
); }; // Material field - AssetField-like style for material selection interface MaterialFieldProps { label: string; value: string | undefined; onSelect: () => void; onClear: () => void; } const MaterialField: React.FC = ({ label, value, onSelect, onClear }) => { const getFileName = (path: string) => { const parts = path.split(/[\\/]/); return parts[parts.length - 1].replace('.mat', '').replace('.json', ''); }; return (
{/* Thumbnail */}
{/* Right side */}
{/* Dropdown */}
{value ? getFileName(value) : '默认材质'}
{/* Actions */}
{value && ( <> )}
); }; export const TilemapDetailsPanel: React.FC = ({ tilemap, onAddLayer, onRemoveLayer, onMoveLayer, onDuplicateLayer, onTilemapChange, onOpenAssetPicker, onSelectLayerMaterial }) => { const { layers, currentLayer, setCurrentLayer, toggleLayerVisibility, setLayerOpacity, setLayerColor, setLayerHiddenInGame, renameLayer, showCollision, setShowCollision } = useTilemapEditorStore(); // Layer name editing state const [isEditingName, setIsEditingName] = useState(false); const [editingName, setEditingName] = useState(''); // Layer properties state - synced with store const selectedLayer = layers[currentLayer]; const [layerCollides, setLayerCollides] = useState(true); const [overrideCollisionThickness, setOverrideCollisionThickness] = useState(false); const [overrideCollisionOffset, setOverrideCollisionOffset] = useState(false); const [collisionThickness, setCollisionThickness] = useState(50.0); const [collisionOffset, setCollisionOffset] = useState(0.0); // hiddenInEditor is derived from layer visibility (inverse relationship) const hiddenInEditor = selectedLayer ? !selectedLayer.visible : false; const handleHiddenInEditorChange = useCallback((hidden: boolean) => { if (currentLayer >= 0 && currentLayer < layers.length) { toggleLayerVisibility(currentLayer); // Also update tilemap component if (tilemap && tilemap.layers[currentLayer]) { tilemap.layers[currentLayer].visible = !hidden; tilemap.renderDirty = true; onTilemapChange(); } } }, [currentLayer, layers.length, toggleLayerVisibility, tilemap, onTilemapChange]); // Handle eye icon click in layer list const handleLayerVisibilityToggle = useCallback((index: number, e: React.MouseEvent) => { e.stopPropagation(); toggleLayerVisibility(index); // Also update tilemap component if (tilemap && tilemap.layers[index]) { tilemap.layers[index].visible = !tilemap.layers[index].visible; tilemap.renderDirty = true; onTilemapChange(); } }, [toggleLayerVisibility, tilemap, onTilemapChange]); // Handle layer opacity change const handleLayerOpacityChange = useCallback((opacity: number) => { if (currentLayer >= 0 && currentLayer < layers.length) { setLayerOpacity(currentLayer, opacity); // Also update tilemap component if (tilemap && tilemap.layers[currentLayer]) { tilemap.layers[currentLayer].opacity = opacity; tilemap.renderDirty = true; onTilemapChange(); } } }, [currentLayer, layers.length, setLayerOpacity, tilemap, onTilemapChange]); // Handle layer name editing const handleStartEditName = useCallback(() => { if (selectedLayer) { setEditingName(selectedLayer.name); setIsEditingName(true); } }, [selectedLayer]); const handleFinishEditName = useCallback(() => { if (isEditingName && editingName.trim()) { renameLayer(currentLayer, editingName.trim()); // Also update tilemap component if (tilemap && tilemap.layers[currentLayer]) { tilemap.renameLayer(currentLayer, editingName.trim()); onTilemapChange(); } } setIsEditingName(false); }, [isEditingName, editingName, currentLayer, renameLayer, tilemap, onTilemapChange]); const handleNameKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter') { handleFinishEditName(); } else if (e.key === 'Escape') { setIsEditingName(false); } }, [handleFinishEditName]); // Handle layer color change const handleLayerColorChange = useCallback((color: string) => { if (currentLayer >= 0 && currentLayer < layers.length) { setLayerColor(currentLayer, color); if (tilemap) { tilemap.setLayerColor(currentLayer, color); tilemap.renderDirty = true; onTilemapChange(); } } }, [currentLayer, layers.length, setLayerColor, tilemap, onTilemapChange]); // Handle layer hidden in game change const handleHiddenInGameChange = useCallback((hidden: boolean) => { if (currentLayer >= 0 && currentLayer < layers.length) { setLayerHiddenInGame(currentLayer, hidden); if (tilemap) { tilemap.setLayerHiddenInGame(currentLayer, hidden); onTilemapChange(); } } }, [currentLayer, layers.length, setLayerHiddenInGame, tilemap, onTilemapChange]); // Colors for grid (editor settings, not layer properties) const [tileGridColor, setTileGridColor] = useState('#333333'); const [multiTileGridColor, setMultiTileGridColor] = useState('#ff0000'); const [layerGridColor, setLayerGridColor] = useState('#00ff00'); const handleLayerSelect = useCallback((index: number) => { setCurrentLayer(index); }, [setCurrentLayer]); const handleMapWidthChange = useCallback((value: number) => { if (tilemap && value > 0) { tilemap.resize(value, tilemap.height, 'bottom-left'); onTilemapChange(); } }, [tilemap, onTilemapChange]); const handleMapHeightChange = useCallback((value: number) => { if (tilemap && value > 0) { tilemap.resize(tilemap.width, value, 'bottom-left'); onTilemapChange(); } }, [tilemap, onTilemapChange]); const handleTileWidthChange = useCallback((value: number) => { if (tilemap && value > 0) { tilemap.tileWidth = value; onTilemapChange(); } }, [tilemap, onTilemapChange]); const handleTileHeightChange = useCallback((value: number) => { if (tilemap && value > 0) { tilemap.tileHeight = value; onTilemapChange(); } }, [tilemap, onTilemapChange]); if (!tilemap) { return (
未选择瓦片地图
); } return (
细节
{/* Tile Map Section */}
图层 {currentLayer + 1}
{/* Tile Layers List */}
{layers.map((layer, index) => (
handleLayerSelect(index)} > {layer.name}
))}
{/* Layer action buttons */}
{/* Selected Layer Section */}
{isEditingName ? ( setEditingName(e.target.value)} onBlur={handleFinishEditName} onKeyDown={handleNameKeyDown} autoFocus /> ) : ( {selectedLayer?.name || '图层 1'} )} {overrideCollisionThickness && ( )} {overrideCollisionOffset && ( )}
{/* Setup Section */}
{}} step={0.1} /> {}} step={0.1} />
{/* Material Section */}
onSelectLayerMaterial?.(currentLayer)} onClear={() => { tilemap.setLayerMaterial(currentLayer, ''); onTilemapChange(); }} />
{/* Advanced Section */}
{}} /> {}} />
{/* Collision Section */}
); }; export default TilemapDetailsPanel;