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 构建错误并优化构建性能
This commit is contained in:
YHH
2025-12-01 22:28:51 +08:00
committed by GitHub
parent 189714c727
commit b42a7b4e43
468 changed files with 18301 additions and 9075 deletions

View File

@@ -0,0 +1,277 @@
/**
* Layer Panel Component
* 图层面板组件
*/
import React, { useState, useCallback } from 'react';
import { Eye, EyeOff, Lock, Unlock, Plus, Trash2, ChevronUp, ChevronDown, Paintbrush, Shield, Grid3X3 } from 'lucide-react';
import { useTilemapEditorStore, type LayerState } from '../../stores/TilemapEditorStore';
import type { TilemapComponent } from '@esengine/tilemap';
interface LayerPanelProps {
tilemap: TilemapComponent | null;
onAddLayer?: () => void;
onRemoveLayer?: (index: number) => void;
onMoveLayer?: (fromIndex: number, toIndex: number) => void;
}
export const LayerPanel: React.FC<LayerPanelProps> = ({
tilemap,
onAddLayer,
onRemoveLayer,
onMoveLayer,
}) => {
const {
currentLayer,
layers,
setCurrentLayer,
toggleLayerVisibility,
toggleLayerLocked,
setLayerOpacity,
renameLayer,
showCollision,
setShowCollision,
editingCollision,
setEditingCollision,
} = useTilemapEditorStore();
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [editName, setEditName] = useState('');
const handleDoubleClick = useCallback((index: number, name: string) => {
setEditingIndex(index);
setEditName(name);
}, []);
const handleNameSubmit = useCallback((index: number) => {
if (editName.trim()) {
renameLayer(index, editName.trim());
// Also update the tilemap component
if (tilemap && tilemap.layers[index]) {
tilemap.layers[index].name = editName.trim();
}
}
setEditingIndex(null);
}, [editName, renameLayer, tilemap]);
const handleKeyDown = useCallback((e: React.KeyboardEvent, index: number) => {
if (e.key === 'Enter') {
handleNameSubmit(index);
} else if (e.key === 'Escape') {
setEditingIndex(null);
}
}, [handleNameSubmit]);
const handleVisibilityToggle = useCallback((index: number) => {
toggleLayerVisibility(index);
// Also update the tilemap component
if (tilemap && tilemap.layers[index]) {
tilemap.layers[index].visible = !tilemap.layers[index].visible;
tilemap.renderDirty = true;
}
}, [toggleLayerVisibility, tilemap]);
const handleOpacityChange = useCallback((index: number, opacity: number) => {
setLayerOpacity(index, opacity);
// Also update the tilemap component
if (tilemap && tilemap.layers[index]) {
tilemap.layers[index].opacity = opacity;
tilemap.renderDirty = true;
}
}, [setLayerOpacity, tilemap]);
if (!tilemap || layers.length === 0) {
return (
<div className="layer-panel">
<div className="layer-panel-header">
<span></span>
<button
className="icon-button"
onClick={onAddLayer}
title="添加图层"
>
<Plus size={14} />
</button>
</div>
<div className="layer-panel-empty">
</div>
</div>
);
}
return (
<div className="layer-panel">
<div className="layer-panel-header">
<span> ({layers.length})</span>
<div className="layer-panel-actions">
<button
className="icon-button"
onClick={onAddLayer}
title="添加图层"
>
<Plus size={14} />
</button>
</div>
</div>
<div className="layer-list">
{/* Collision Layer - Special layer */}
<div
className={`layer-item collision-layer ${editingCollision ? 'selected' : ''}`}
onClick={() => {
setEditingCollision(true);
// Auto-show collision when editing
if (!showCollision) {
setShowCollision(true);
}
}}
>
<div className="layer-controls">
<button
className="icon-button small"
onClick={(e) => {
e.stopPropagation();
setShowCollision(!showCollision);
}}
title={showCollision ? '隐藏碰撞层' : '显示碰撞层'}
>
{showCollision ? <Eye size={12} /> : <EyeOff size={12} />}
</button>
</div>
<div className="layer-info">
{editingCollision && (
<span className="layer-active-indicator" title="当前编辑碰撞">
<Shield size={14} />
</span>
)}
<span className="layer-name collision-name">
<Shield size={12} style={{ marginRight: 4, opacity: 0.7 }} />
</span>
</div>
</div>
{/* Separator */}
<div className="layer-separator" />
{/* Tile Layers */}
{layers.map((layer, index) => (
<div
key={layer.id}
className={`layer-item ${index === currentLayer && !editingCollision ? 'selected' : ''} ${layer.locked ? 'locked' : ''}`}
onClick={() => {
setEditingCollision(false);
setCurrentLayer(index);
}}
>
<div className="layer-controls">
<button
className="icon-button small"
onClick={(e) => {
e.stopPropagation();
handleVisibilityToggle(index);
}}
title={layer.visible ? '隐藏图层' : '显示图层'}
>
{layer.visible ? <Eye size={12} /> : <EyeOff size={12} />}
</button>
<button
className="icon-button small"
onClick={(e) => {
e.stopPropagation();
toggleLayerLocked(index);
}}
title={layer.locked ? '解锁图层' : '锁定图层'}
>
{layer.locked ? <Lock size={12} /> : <Unlock size={12} />}
</button>
</div>
<div className="layer-info">
{index === currentLayer && (
<span className="layer-active-indicator" title="当前绘制图层">
<Paintbrush size={14} />
</span>
)}
{editingIndex === index ? (
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={() => handleNameSubmit(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
autoFocus
className="layer-name-input"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="layer-name"
onDoubleClick={() => handleDoubleClick(index, layer.name)}
>
{layer.name}
</span>
)}
</div>
<div className="layer-actions">
<button
className="icon-button small"
onClick={(e) => {
e.stopPropagation();
onMoveLayer?.(index, index - 1);
}}
disabled={index === 0}
title="上移图层"
>
<ChevronUp size={12} />
</button>
<button
className="icon-button small"
onClick={(e) => {
e.stopPropagation();
onMoveLayer?.(index, index + 1);
}}
disabled={index === layers.length - 1}
title="下移图层"
>
<ChevronDown size={12} />
</button>
<button
className="icon-button small danger"
onClick={(e) => {
e.stopPropagation();
onRemoveLayer?.(index);
}}
disabled={layers.length <= 1}
title="删除图层"
>
<Trash2 size={12} />
</button>
</div>
</div>
))}
</div>
{/* Opacity slider for selected layer */}
{layers[currentLayer] && (
<div className="layer-opacity-control">
<label>Opacity</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={layers[currentLayer].opacity}
onChange={(e) => handleOpacityChange(currentLayer, parseFloat(e.target.value))}
title={`Opacity for ${layers[currentLayer].name}`}
/>
<span>{Math.round(layers[currentLayer].opacity * 100)}%</span>
</div>
)}
</div>
);
};
export default LayerPanel;

View File

@@ -0,0 +1,174 @@
/**
* Tile Set Selector Panel - Left panel for selecting tiles
* 瓦片集选择面板 - 左侧面板用于选择瓦片
*/
import React, { useState, useCallback } from 'react';
import { Paintbrush, Eraser, PaintBucket, ChevronDown, Grid3x3, Search } from 'lucide-react';
import { useTilemapEditorStore, type TilemapToolType } from '../../stores/TilemapEditorStore';
import { TilesetPreview } from '../TilesetPreview';
import '../../styles/TileSetSelectorPanel.css';
interface TilesetOption {
name: string;
path: string;
}
interface TileSetSelectorPanelProps {
tilesets: TilesetOption[];
activeTilesetIndex: number;
onTilesetChange: (index: number) => void;
onAddTileset: () => void;
}
export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
tilesets,
activeTilesetIndex,
onTilesetChange,
onAddTileset
}) => {
const {
currentTool,
setCurrentTool,
tilesetImageUrl,
tileWidth,
tileHeight,
tilesetColumns,
tilesetRows,
selectedTiles
} = useTilemapEditorStore();
const [showTilesetDropdown, setShowTilesetDropdown] = useState(false);
const [previewZoom, setPreviewZoom] = useState(1);
const handleToolChange = useCallback((tool: TilemapToolType) => {
setCurrentTool(tool);
}, [setCurrentTool]);
const activeTileset = tilesets[activeTilesetIndex];
return (
<div className="tileset-selector-panel">
{/* Tool buttons */}
<div className="tileset-tools">
<button
className={`tileset-tool-btn ${currentTool === 'brush' ? 'active' : ''}`}
onClick={() => handleToolChange('brush')}
title="绘制"
>
<Paintbrush size={24} />
<span></span>
</button>
<button
className={`tileset-tool-btn ${currentTool === 'eraser' ? 'active' : ''}`}
onClick={() => handleToolChange('eraser')}
title="橡皮擦"
>
<Eraser size={24} />
<span></span>
</button>
<button
className={`tileset-tool-btn ${currentTool === 'fill' ? 'active' : ''}`}
onClick={() => handleToolChange('fill')}
title="填充"
>
<PaintBucket size={24} />
<span></span>
</button>
</div>
{/* Active Tile Set selector */}
<div className="tileset-selector">
<div className="tileset-selector-row">
<label></label>
<div className="tileset-selector-actions">
<button className="tileset-action-btn" title="显示网格">
<Grid3x3 size={14} />
</button>
<button className="tileset-action-btn" title="搜索">
<Search size={14} />
</button>
</div>
</div>
<div className="tileset-dropdown-wrapper">
<button
className="tileset-dropdown-btn"
onClick={() => setShowTilesetDropdown(!showTilesetDropdown)}
>
<span>{activeTileset?.name || '(无)'}</span>
<ChevronDown size={14} />
</button>
{showTilesetDropdown && (
<div className="tileset-dropdown-menu">
<button
className={`tileset-dropdown-item ${activeTilesetIndex === -1 ? 'selected' : ''}`}
onClick={() => {
onTilesetChange(-1);
setShowTilesetDropdown(false);
}}
>
()
</button>
{tilesets.map((tileset, index) => (
<button
key={tileset.path}
className={`tileset-dropdown-item ${index === activeTilesetIndex ? 'selected' : ''}`}
onClick={() => {
onTilesetChange(index);
setShowTilesetDropdown(false);
}}
>
{tileset.name}
</button>
))}
<div className="tileset-dropdown-divider" />
<button
className="tileset-dropdown-item add-new"
onClick={() => {
onAddTileset();
setShowTilesetDropdown(false);
}}
>
+ ...
</button>
</div>
)}
</div>
</div>
{/* Zoom and title header */}
<div className="tileset-header">
<span className="tileset-zoom-label">{previewZoom}:1</span>
<span className="tileset-title"></span>
</div>
{/* Tile preview area */}
<div className="tileset-preview-area">
{tilesetImageUrl ? (
<TilesetPreview
imageUrl={tilesetImageUrl}
tileWidth={tileWidth}
tileHeight={tileHeight}
columns={tilesetColumns}
rows={tilesetRows}
/>
) : (
<div className="tileset-empty-hint">
<button className="tileset-select-btn" onClick={onAddTileset}>
</button>
</div>
)}
</div>
{/* Selection info */}
{selectedTiles && (
<div className="tileset-selection-info">
: {selectedTiles.width}×{selectedTiles.height}
</div>
)}
</div>
);
};
export default TileSetSelectorPanel;

View File

@@ -0,0 +1,521 @@
/**
* 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
} 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;
onTilemapChange: () => void;
onOpenAssetPicker: () => void;
}
// Collapsible section component
interface SectionProps {
title: string;
defaultOpen?: boolean;
children: React.ReactNode;
}
const Section: React.FC<SectionProps> = ({ title, defaultOpen = true, children }) => {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="details-section">
<div
className="details-section-header"
onClick={() => setIsOpen(!isOpen)}
>
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span>{title}</span>
</div>
{isOpen && (
<div className="details-section-content">
{children}
</div>
)}
</div>
);
};
// Property row component
interface PropertyRowProps {
label: string;
children: React.ReactNode;
indent?: boolean;
}
const PropertyRow: React.FC<PropertyRowProps> = ({ label, children, indent }) => (
<div className={`property-row ${indent ? 'indented' : ''}`}>
<label>{label}</label>
<div className="property-value">{children}</div>
</div>
);
// Toggle property - unified style matching PropertyInspector
interface TogglePropertyProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
indent?: boolean;
}
const ToggleProperty: React.FC<TogglePropertyProps> = ({ label, checked, onChange, indent }) => (
<div className={`property-row toggle-row ${indent ? 'indented' : ''}`}>
<label>{label}</label>
<button
className={`property-toggle ${checked ? 'property-toggle-on' : 'property-toggle-off'}`}
onClick={() => onChange(!checked)}
type="button"
>
<span className="property-toggle-thumb" />
</button>
</div>
);
// Number input property
interface NumberPropertyProps {
label: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
}
const NumberProperty: React.FC<NumberPropertyProps> = ({
label,
value,
onChange,
min,
max,
step = 1
}) => (
<PropertyRow label={label}>
<input
type="number"
value={value}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
min={min}
max={max}
step={step}
/>
</PropertyRow>
);
// Color property - unified style matching PropertyInspector
interface ColorPropertyProps {
label: string;
value: string;
onChange: (value: string) => void;
showReset?: boolean;
}
const ColorProperty: React.FC<ColorPropertyProps> = ({ label, value, onChange }) => {
const inputRef = useRef<HTMLInputElement>(null);
const handlePreviewClick = () => {
inputRef.current?.click();
};
return (
<PropertyRow label={label}>
<div className="property-color-wrapper">
<div
className="property-color-preview"
style={{ backgroundColor: value }}
onClick={handlePreviewClick}
/>
<input
ref={inputRef}
type="color"
className="property-input-color"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<input
type="text"
className="property-input property-input-color-text"
value={value.toUpperCase()}
onChange={(e) => {
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);
}
}}
/>
</div>
</PropertyRow>
);
};
export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
tilemap,
onAddLayer,
onRemoveLayer,
onMoveLayer,
onTilemapChange,
onOpenAssetPicker
}) => {
const {
layers,
currentLayer,
setCurrentLayer,
toggleLayerVisibility,
setLayerOpacity,
showCollision,
setShowCollision
} = useTilemapEditorStore();
// Layer properties state - synced with store's visibility
const selectedLayer = layers[currentLayer];
const [hiddenInGame, setHiddenInGame] = useState(false);
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);
const [layerColor, setLayerColor] = useState('#ffffff');
// 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]);
// Colors
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 (
<div className="tilemap-details-panel">
<div className="details-empty">
</div>
</div>
);
}
return (
<div className="tilemap-details-panel">
<div className="details-header">
<span className="details-header-title"></span>
<div className="details-header-actions">
<div className="details-search-inline">
<Search size={12} />
<input type="text" placeholder="搜索" />
</div>
<button className="details-settings-btn" title="设置">
<Settings size={14} />
</button>
</div>
</div>
<div className="details-content">
{/* Tile Map Section */}
<Section title="瓦片地图">
<div className="property-row">
<label></label>
<span className="layer-count-badge"> {currentLayer + 1}</span>
</div>
{/* Tile Layers List */}
<div className="layer-list-container">
{layers.map((layer, index) => (
<div
key={layer.id}
className={`layer-list-item ${index === currentLayer ? 'selected' : ''}`}
onClick={() => handleLayerSelect(index)}
>
<button
className={`layer-visibility-btn ${layer.visible ? '' : 'hidden'}`}
onClick={(e) => handleLayerVisibilityToggle(index, e)}
title={layer.visible ? '隐藏图层' : '显示图层'}
>
{layer.visible ? <Eye size={12} /> : <EyeOff size={12} />}
</button>
<span className="layer-icon"></span>
<span>{layer.name}</span>
</div>
))}
</div>
{/* Layer action buttons */}
<div className="layer-actions-row">
<button
className="layer-action-btn add"
onClick={onAddLayer}
title="添加图层"
>
<Plus size={14} />
</button>
<button
className="layer-action-btn"
onClick={() => currentLayer > 0 && onMoveLayer(currentLayer, currentLayer - 1)}
disabled={currentLayer <= 0}
title="上移"
>
<ArrowUp size={14} />
</button>
<button
className="layer-action-btn"
onClick={() => currentLayer < layers.length - 1 && onMoveLayer(currentLayer, currentLayer + 1)}
disabled={currentLayer >= layers.length - 1}
title="下移"
>
<ArrowDown size={14} />
</button>
<button
className="layer-action-btn"
title="复制"
>
<Copy size={14} />
</button>
<button
className="layer-action-btn danger"
onClick={() => layers.length > 1 && onRemoveLayer(currentLayer)}
disabled={layers.length <= 1}
title="删除图层"
>
<X size={14} />
</button>
</div>
</Section>
{/* Selected Layer Section */}
<Section title="选定层">
<PropertyRow label="">
<span className="selected-layer-name">{selectedLayer?.name || '图层 1'}</span>
</PropertyRow>
<ToggleProperty
label="编辑器中隐藏"
checked={hiddenInEditor}
onChange={handleHiddenInEditorChange}
/>
<ToggleProperty
label="游戏中隐藏"
checked={hiddenInGame}
onChange={setHiddenInGame}
/>
<ToggleProperty
label="图层碰撞"
checked={layerCollides}
onChange={setLayerCollides}
/>
<ToggleProperty
label="重载碰撞厚度"
checked={overrideCollisionThickness}
onChange={setOverrideCollisionThickness}
indent
/>
<ToggleProperty
label="重载碰撞偏移"
checked={overrideCollisionOffset}
onChange={setOverrideCollisionOffset}
indent
/>
{overrideCollisionThickness && (
<NumberProperty
label="碰撞厚度重载"
value={collisionThickness}
onChange={setCollisionThickness}
/>
)}
{overrideCollisionOffset && (
<NumberProperty
label="碰撞偏移重载"
value={collisionOffset}
onChange={setCollisionOffset}
/>
)}
<ColorProperty
label="图层颜色"
value={layerColor}
onChange={setLayerColor}
/>
</Section>
{/* Setup Section */}
<Section title="配置">
<NumberProperty
label="地图宽度"
value={tilemap.width}
onChange={handleMapWidthChange}
min={1}
/>
<NumberProperty
label="地图高度"
value={tilemap.height}
onChange={handleMapHeightChange}
min={1}
/>
<NumberProperty
label="瓦片宽度"
value={tilemap.tileWidth}
onChange={handleTileWidthChange}
min={1}
/>
<NumberProperty
label="瓦片高度"
value={tilemap.tileHeight}
onChange={handleTileHeightChange}
min={1}
/>
<NumberProperty
label="逻辑单位像素"
value={1.0}
onChange={() => {}}
step={0.1}
/>
<NumberProperty
label="逐图层分隔"
value={4.0}
onChange={() => {}}
step={0.1}
/>
</Section>
{/* Material Section */}
<Section title="材质" defaultOpen={false}>
<PropertyRow label="材质">
<button className="asset-dropdown">
<span>Masked</span>
<ChevronDown size={12} />
</button>
</PropertyRow>
</Section>
{/* Advanced Section */}
<Section title="高级" defaultOpen={false}>
<PropertyRow label="投射模式">
<select defaultValue="orthogonal">
<option value="orthogonal"></option>
<option value="isometric"></option>
<option value="hexagonal"></option>
</select>
</PropertyRow>
<NumberProperty
label="六方格边长度"
value={0}
onChange={() => {}}
/>
<ColorProperty
label="背景颜色"
value={tileGridColor}
onChange={setTileGridColor}
showReset
/>
<ColorProperty
label="瓦片网格颜色"
value={tileGridColor}
onChange={setTileGridColor}
showReset
/>
<ColorProperty
label="多瓦片网格颜色"
value={multiTileGridColor}
onChange={setMultiTileGridColor}
showReset
/>
<NumberProperty
label="多瓦片网格宽度"
value={0}
onChange={() => {}}
/>
</Section>
{/* Collision Section */}
<Section title="碰撞" defaultOpen={false}>
<ToggleProperty
label="显示碰撞"
checked={showCollision}
onChange={setShowCollision}
/>
</Section>
</div>
</div>
);
};
export default TilemapDetailsPanel;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
/**
* Tileset Panel - Display tileset for selection
*/
import React, { useEffect, useCallback } from 'react';
import { Core } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { TilemapComponent, type ITilesetData } from '@esengine/tilemap';
import { useTilemapEditorStore } from '../../stores/TilemapEditorStore';
import { TilesetPreview } from '../TilesetPreview';
import '../../styles/TilemapEditor.css';
// Helper to convert file path to URL
function convertFileSrc(path: string): string {
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('asset://')) {
return path;
}
return `asset://localhost/${encodeURIComponent(path)}`;
}
interface TilesetPanelProps {
projectPath?: string | null;
}
export const TilesetPanel: React.FC<TilesetPanelProps> = () => {
const {
entityId,
tilesetImageUrl,
tilesetColumns,
tilesetRows,
tileWidth,
tileHeight,
selectedTiles,
setTileset
} = useTilemapEditorStore();
// Load tileset from component
const loadTilesetFromComponent = useCallback(() => {
if (!entityId) 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;
// Get tileset source from first tileset
const tilesetRef = tilemapComp.tilesets[0];
if (!tilesetRef) return;
const tilesetPath = tilesetRef.source;
const imageUrl = convertFileSrc(tilesetPath);
const currentState = useTilemapEditorStore.getState();
// Check if URL or tile dimensions changed
const urlChanged = imageUrl !== currentState.tilesetImageUrl;
const dimensionsChanged =
tilemapComp.tileWidth !== currentState.tileWidth ||
tilemapComp.tileHeight !== currentState.tileHeight;
if (!urlChanged && !dimensionsChanged) return;
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.src = imageUrl;
}, [entityId, setTileset]);
// Load tileset when entityId is set but tilesetImageUrl is not yet loaded
useEffect(() => {
if (!entityId || tilesetImageUrl) return;
loadTilesetFromComponent();
}, [entityId, tilesetImageUrl, loadTilesetFromComponent]);
// Listen for scene modifications to reload tileset when property changes
useEffect(() => {
if (!entityId) return;
const messageHub = Core.services.resolve(MessageHub);
if (!messageHub) return;
const unsubscribe = messageHub.subscribe('scene:modified', () => {
loadTilesetFromComponent();
});
return unsubscribe;
}, [entityId, loadTilesetFromComponent]);
if (!tilesetImageUrl) {
return (
<div className="tileset-panel">
<div className="tileset-panel-header">
<h3>Tileset</h3>
</div>
<div className="tileset-empty">
<p>
No tileset loaded.
<br />
Select a TilemapComponent to edit.
</p>
</div>
</div>
);
}
return (
<div className="tileset-panel">
<div className="tileset-panel-header">
<h3>Tileset</h3>
</div>
<div className="tileset-canvas-container">
<TilesetPreview
imageUrl={tilesetImageUrl}
tileWidth={tileWidth}
tileHeight={tileHeight}
columns={tilesetColumns}
rows={tilesetRows}
/>
</div>
{selectedTiles && (
<div className="tilemap-info-bar">
<span>
Selected: {selectedTiles.width}×{selectedTiles.height}
</span>
<span>Tile: {selectedTiles.tiles[0]}</span>
</div>
)}
</div>
);
};