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:
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Tile Animation Editor Panel
|
||||
* 瓦片动画编辑器面板
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { X, Play, Pause, Trash2, GripVertical } from 'lucide-react';
|
||||
import type { ITileAnimation, ITileAnimationFrame, ITilesetData } from '@esengine/tilemap';
|
||||
import '../../styles/TileAnimationEditor.css';
|
||||
|
||||
interface TileAnimationEditorProps {
|
||||
tileId: number;
|
||||
tileset: ITilesetData;
|
||||
tilesetImage: HTMLImageElement | null;
|
||||
animation: ITileAnimation | null;
|
||||
onAnimationChange: (animation: ITileAnimation | null) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const TileAnimationEditor: React.FC<TileAnimationEditorProps> = ({
|
||||
tileId,
|
||||
tileset,
|
||||
tilesetImage,
|
||||
animation,
|
||||
onAnimationChange,
|
||||
onClose
|
||||
}) => {
|
||||
const [frames, setFrames] = useState<ITileAnimationFrame[]>(
|
||||
animation?.frames ?? []
|
||||
);
|
||||
const [defaultDuration, setDefaultDuration] = useState(100);
|
||||
const [isPlaying, setIsPlaying] = useState(true);
|
||||
const [currentPreviewFrame, setCurrentPreviewFrame] = useState(0);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const tilesetCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationTimerRef = useRef<number | null>(null);
|
||||
const lastFrameTimeRef = useRef<number>(0);
|
||||
|
||||
const { tileWidth, tileHeight, columns } = tileset;
|
||||
|
||||
// Draw a single tile on canvas
|
||||
const drawTile = useCallback((
|
||||
ctx: CanvasRenderingContext2D,
|
||||
tileIndex: number,
|
||||
destX: number,
|
||||
destY: number,
|
||||
destWidth: number,
|
||||
destHeight: number
|
||||
) => {
|
||||
if (!tilesetImage) return;
|
||||
|
||||
const srcX = (tileIndex % columns) * tileWidth;
|
||||
const srcY = Math.floor(tileIndex / columns) * tileHeight;
|
||||
|
||||
ctx.drawImage(
|
||||
tilesetImage,
|
||||
srcX, srcY, tileWidth, tileHeight,
|
||||
destX, destY, destWidth, destHeight
|
||||
);
|
||||
}, [tilesetImage, columns, tileWidth, tileHeight]);
|
||||
|
||||
// Animation preview loop
|
||||
useEffect(() => {
|
||||
if (!isPlaying || frames.length === 0) return;
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!lastFrameTimeRef.current) {
|
||||
lastFrameTimeRef.current = timestamp;
|
||||
}
|
||||
|
||||
const elapsed = timestamp - lastFrameTimeRef.current;
|
||||
const currentFrame = frames[currentPreviewFrame];
|
||||
|
||||
if (currentFrame && elapsed >= currentFrame.duration) {
|
||||
lastFrameTimeRef.current = timestamp;
|
||||
setCurrentPreviewFrame((prev) => (prev + 1) % frames.length);
|
||||
}
|
||||
|
||||
animationTimerRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animationTimerRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationTimerRef.current) {
|
||||
cancelAnimationFrame(animationTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPlaying, frames, currentPreviewFrame]);
|
||||
|
||||
// Draw preview canvas
|
||||
useEffect(() => {
|
||||
const canvas = previewCanvasRef.current;
|
||||
if (!canvas || !tilesetImage) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = '#1a1a1a';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (frames.length > 0) {
|
||||
const frame = frames[currentPreviewFrame];
|
||||
if (frame) {
|
||||
drawTile(ctx, frame.tileId, 16, 16, 64, 64);
|
||||
}
|
||||
} else {
|
||||
drawTile(ctx, tileId, 16, 16, 64, 64);
|
||||
}
|
||||
}, [frames, currentPreviewFrame, tileId, tilesetImage, drawTile]);
|
||||
|
||||
// Draw tileset selector canvas
|
||||
useEffect(() => {
|
||||
const canvas = tilesetCanvasRef.current;
|
||||
if (!canvas || !tilesetImage) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = tilesetImage.width;
|
||||
canvas.height = tilesetImage.height;
|
||||
|
||||
ctx.drawImage(tilesetImage, 0, 0);
|
||||
|
||||
// Highlight animated tiles
|
||||
const animatedTileIds = new Set(frames.map(f => f.tileId));
|
||||
ctx.strokeStyle = '#00ff00';
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
for (const id of animatedTileIds) {
|
||||
const x = (id % columns) * tileWidth;
|
||||
const y = Math.floor(id / columns) * tileHeight;
|
||||
ctx.strokeRect(x + 1, y + 1, tileWidth - 2, tileHeight - 2);
|
||||
}
|
||||
|
||||
// Highlight source tile
|
||||
ctx.strokeStyle = '#ffff00';
|
||||
const srcX = (tileId % columns) * tileWidth;
|
||||
const srcY = Math.floor(tileId / columns) * tileHeight;
|
||||
ctx.strokeRect(srcX + 1, srcY + 1, tileWidth - 2, tileHeight - 2);
|
||||
}, [tilesetImage, frames, tileId, columns, tileWidth, tileHeight]);
|
||||
|
||||
// Handle tileset click to add frame
|
||||
const handleTilesetClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = tilesetCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const x = (e.clientX - rect.left) * scaleX;
|
||||
const y = (e.clientY - rect.top) * scaleY;
|
||||
|
||||
const col = Math.floor(x / tileWidth);
|
||||
const row = Math.floor(y / tileHeight);
|
||||
const clickedTileId = row * columns + col;
|
||||
|
||||
if (clickedTileId >= 0 && clickedTileId < tileset.tileCount) {
|
||||
setFrames([...frames, { tileId: clickedTileId, duration: defaultDuration }]);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle frame duration change
|
||||
const handleDurationChange = (index: number, duration: number) => {
|
||||
const newFrames = [...frames];
|
||||
newFrames[index] = { ...newFrames[index], duration: Math.max(10, duration) };
|
||||
setFrames(newFrames);
|
||||
};
|
||||
|
||||
// Handle frame delete
|
||||
const handleDeleteFrame = (index: number) => {
|
||||
setFrames(frames.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// Handle frame reorder via drag
|
||||
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||
e.dataTransfer.setData('frameIndex', index.toString());
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
setDragOverIndex(index);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
|
||||
e.preventDefault();
|
||||
const dragIndex = parseInt(e.dataTransfer.getData('frameIndex'), 10);
|
||||
if (dragIndex === dropIndex) {
|
||||
setDragOverIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const newFrames = [...frames];
|
||||
const [draggedFrame] = newFrames.splice(dragIndex, 1);
|
||||
newFrames.splice(dropIndex, 0, draggedFrame);
|
||||
setFrames(newFrames);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
// Apply changes
|
||||
const handleApply = () => {
|
||||
if (frames.length === 0) {
|
||||
onAnimationChange(null);
|
||||
} else {
|
||||
onAnimationChange({ frames });
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Clear animation
|
||||
const handleClear = () => {
|
||||
setFrames([]);
|
||||
setCurrentPreviewFrame(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tile-animation-editor-overlay">
|
||||
<div className="tile-animation-editor">
|
||||
<div className="animation-editor-header">
|
||||
<h3>瓦片动画编辑器 - 瓦片 #{tileId}</h3>
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="animation-editor-content">
|
||||
{/* Preview section */}
|
||||
<div className="animation-preview-section">
|
||||
<div className="preview-box">
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
width={96}
|
||||
height={96}
|
||||
className="animation-preview-canvas"
|
||||
/>
|
||||
</div>
|
||||
<div className="preview-controls">
|
||||
<button
|
||||
className={`preview-btn ${isPlaying ? 'active' : ''}`}
|
||||
onClick={() => setIsPlaying(!isPlaying)}
|
||||
title={isPlaying ? '暂停' : '播放'}
|
||||
>
|
||||
{isPlaying ? <Pause size={16} /> : <Play size={16} />}
|
||||
</button>
|
||||
<span className="frame-indicator">
|
||||
{frames.length > 0 ? `${currentPreviewFrame + 1}/${frames.length}` : '无帧'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Frame list */}
|
||||
<div className="animation-frames-section">
|
||||
<div className="frames-header">
|
||||
<span>动画帧</span>
|
||||
<span className="frame-count">{frames.length} 帧</span>
|
||||
</div>
|
||||
<div className="frames-list">
|
||||
{frames.map((frame, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`frame-item ${dragOverIndex === index ? 'drag-over' : ''}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
>
|
||||
<div className="frame-drag-handle">
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
<div className="frame-preview">
|
||||
<canvas
|
||||
width={32}
|
||||
height={32}
|
||||
ref={(canvas) => {
|
||||
if (canvas && tilesetImage) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, 32, 32);
|
||||
drawTile(ctx, frame.tileId, 0, 0, 32, 32);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="frame-info">
|
||||
<span className="frame-tile-id">#{frame.tileId}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="frame-duration-input"
|
||||
value={frame.duration}
|
||||
onChange={(e) => handleDurationChange(index, parseInt(e.target.value, 10) || 100)}
|
||||
min={10}
|
||||
step={10}
|
||||
/>
|
||||
<span className="duration-unit">ms</span>
|
||||
</div>
|
||||
<button
|
||||
className="frame-delete-btn"
|
||||
onClick={() => handleDeleteFrame(index)}
|
||||
title="删除帧"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{frames.length === 0 && (
|
||||
<div className="frames-empty">
|
||||
点击下方瓦片添加动画帧
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tileset selector */}
|
||||
<div className="animation-tileset-section">
|
||||
<div className="tileset-header">
|
||||
<span>点击瓦片添加帧</span>
|
||||
<div className="default-duration">
|
||||
<label>默认时长:</label>
|
||||
<input
|
||||
type="number"
|
||||
value={defaultDuration}
|
||||
onChange={(e) => setDefaultDuration(parseInt(e.target.value, 10) || 100)}
|
||||
min={10}
|
||||
step={10}
|
||||
/>
|
||||
<span>ms</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tileset-scroll-container">
|
||||
<canvas
|
||||
ref={tilesetCanvasRef}
|
||||
className="animation-tileset-canvas"
|
||||
onClick={handleTilesetClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer buttons */}
|
||||
<div className="animation-editor-footer">
|
||||
<button className="btn-secondary" onClick={handleClear}>
|
||||
清除动画
|
||||
</button>
|
||||
<div className="footer-right">
|
||||
<button className="btn-secondary" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handleApply}>
|
||||
应用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TileAnimationEditor;
|
||||
@@ -3,10 +3,12 @@
|
||||
* 瓦片集选择面板 - 左侧面板用于选择瓦片
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Paintbrush, Eraser, PaintBucket, ChevronDown, Grid3x3, Search } from 'lucide-react';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Paintbrush, Eraser, PaintBucket, ChevronDown, Grid3x3, Search, Box, Square, BoxSelect } from 'lucide-react';
|
||||
import { useTilemapEditorStore, type TilemapToolType } from '../../stores/TilemapEditorStore';
|
||||
import { TilesetPreview } from '../TilesetPreview';
|
||||
import { TileAnimationEditor } from './TileAnimationEditor';
|
||||
import type { ITilesetData, ITileAnimation } from '@esengine/tilemap';
|
||||
import '../../styles/TileSetSelectorPanel.css';
|
||||
|
||||
interface TilesetOption {
|
||||
@@ -17,15 +19,21 @@ interface TilesetOption {
|
||||
interface TileSetSelectorPanelProps {
|
||||
tilesets: TilesetOption[];
|
||||
activeTilesetIndex: number;
|
||||
activeTileset?: ITilesetData;
|
||||
tilesetImage?: HTMLImageElement | null;
|
||||
onTilesetChange: (index: number) => void;
|
||||
onAddTileset: () => void;
|
||||
onTileAnimationChange?: (tileId: number, animation: ITileAnimation | null) => void;
|
||||
}
|
||||
|
||||
export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
||||
tilesets,
|
||||
activeTilesetIndex,
|
||||
activeTileset,
|
||||
tilesetImage,
|
||||
onTilesetChange,
|
||||
onAddTileset
|
||||
onAddTileset,
|
||||
onTileAnimationChange
|
||||
}) => {
|
||||
const {
|
||||
currentTool,
|
||||
@@ -35,26 +43,93 @@ export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
||||
tileHeight,
|
||||
tilesetColumns,
|
||||
tilesetRows,
|
||||
selectedTiles
|
||||
selectedTiles,
|
||||
editingCollision,
|
||||
setEditingCollision
|
||||
} = useTilemapEditorStore();
|
||||
|
||||
const [showTilesetDropdown, setShowTilesetDropdown] = useState(false);
|
||||
const [previewZoom, setPreviewZoom] = useState(1);
|
||||
const [previewZoom, _setPreviewZoom] = useState(1);
|
||||
const [editingAnimationTileId, setEditingAnimationTileId] = useState<number | null>(null);
|
||||
|
||||
// Get animated tile IDs from tileset
|
||||
const animatedTileIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
if (activeTileset?.tiles) {
|
||||
for (const tile of activeTileset.tiles) {
|
||||
if (tile.animation && tile.animation.frames.length > 0) {
|
||||
ids.add(tile.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}, [activeTileset]);
|
||||
|
||||
// Get current animation for editing tile
|
||||
const editingTileAnimation = useMemo(() => {
|
||||
if (editingAnimationTileId === null || !activeTileset?.tiles) return null;
|
||||
const tile = activeTileset.tiles.find(t => t.id === editingAnimationTileId);
|
||||
return tile?.animation ?? null;
|
||||
}, [editingAnimationTileId, activeTileset]);
|
||||
|
||||
const handleEditAnimation = useCallback((tileId: number) => {
|
||||
setEditingAnimationTileId(tileId);
|
||||
}, []);
|
||||
|
||||
const handleAnimationChange = useCallback((animation: ITileAnimation | null) => {
|
||||
if (editingAnimationTileId !== null && onTileAnimationChange) {
|
||||
onTileAnimationChange(editingAnimationTileId, animation);
|
||||
}
|
||||
}, [editingAnimationTileId, onTileAnimationChange]);
|
||||
|
||||
const handleCloseAnimationEditor = useCallback(() => {
|
||||
setEditingAnimationTileId(null);
|
||||
}, []);
|
||||
|
||||
const handleToolChange = useCallback((tool: TilemapToolType) => {
|
||||
setCurrentTool(tool);
|
||||
}, [setCurrentTool]);
|
||||
|
||||
const activeTileset = tilesets[activeTilesetIndex];
|
||||
const { setShowCollision } = useTilemapEditorStore();
|
||||
|
||||
const handleToggleCollisionMode = useCallback((enabled: boolean) => {
|
||||
setEditingCollision(enabled);
|
||||
// 启用碰撞编辑时自动显示碰撞
|
||||
if (enabled) {
|
||||
setShowCollision(true);
|
||||
}
|
||||
}, [setEditingCollision, setShowCollision]);
|
||||
|
||||
const activeTilesetOption = tilesets[activeTilesetIndex];
|
||||
|
||||
return (
|
||||
<div className="tileset-selector-panel">
|
||||
{/* Mode toggle */}
|
||||
<div className="tileset-mode-toggle">
|
||||
<button
|
||||
className={`mode-toggle-btn ${!editingCollision ? 'active' : ''}`}
|
||||
onClick={() => handleToggleCollisionMode(false)}
|
||||
title="瓦片编辑模式"
|
||||
>
|
||||
<Paintbrush size={14} />
|
||||
<span>瓦片</span>
|
||||
</button>
|
||||
<button
|
||||
className={`mode-toggle-btn ${editingCollision ? 'active' : ''}`}
|
||||
onClick={() => handleToggleCollisionMode(true)}
|
||||
title="碰撞编辑模式"
|
||||
>
|
||||
<Box size={14} />
|
||||
<span>碰撞</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tool buttons */}
|
||||
<div className="tileset-tools">
|
||||
<button
|
||||
className={`tileset-tool-btn ${currentTool === 'brush' ? 'active' : ''}`}
|
||||
onClick={() => handleToolChange('brush')}
|
||||
title="绘制"
|
||||
title={editingCollision ? "绘制碰撞" : "绘制瓦片"}
|
||||
>
|
||||
<Paintbrush size={24} />
|
||||
<span>绘制</span>
|
||||
@@ -62,7 +137,7 @@ export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
||||
<button
|
||||
className={`tileset-tool-btn ${currentTool === 'eraser' ? 'active' : ''}`}
|
||||
onClick={() => handleToolChange('eraser')}
|
||||
title="橡皮擦"
|
||||
title={editingCollision ? "擦除碰撞" : "擦除瓦片"}
|
||||
>
|
||||
<Eraser size={24} />
|
||||
<span>橡皮擦</span>
|
||||
@@ -70,11 +145,27 @@ export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
||||
<button
|
||||
className={`tileset-tool-btn ${currentTool === 'fill' ? 'active' : ''}`}
|
||||
onClick={() => handleToolChange('fill')}
|
||||
title="填充"
|
||||
title={editingCollision ? "填充碰撞" : "填充瓦片"}
|
||||
>
|
||||
<PaintBucket size={24} />
|
||||
<span>填充</span>
|
||||
</button>
|
||||
<button
|
||||
className={`tileset-tool-btn ${currentTool === 'rectangle' ? 'active' : ''}`}
|
||||
onClick={() => handleToolChange('rectangle')}
|
||||
title={editingCollision ? "矩形碰撞" : "矩形绘制"}
|
||||
>
|
||||
<Square size={24} />
|
||||
<span>矩形</span>
|
||||
</button>
|
||||
<button
|
||||
className={`tileset-tool-btn ${currentTool === 'select' ? 'active' : ''}`}
|
||||
onClick={() => handleToolChange('select')}
|
||||
title="选择区域"
|
||||
>
|
||||
<BoxSelect size={24} />
|
||||
<span>选择</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active Tile Set selector */}
|
||||
@@ -95,7 +186,7 @@ export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
||||
className="tileset-dropdown-btn"
|
||||
onClick={() => setShowTilesetDropdown(!showTilesetDropdown)}
|
||||
>
|
||||
<span>{activeTileset?.name || '(无)'}</span>
|
||||
<span>{activeTilesetOption?.name || '(无)'}</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{showTilesetDropdown && (
|
||||
@@ -144,13 +235,23 @@ export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
||||
|
||||
{/* Tile preview area */}
|
||||
<div className="tileset-preview-area">
|
||||
{tilesetImageUrl ? (
|
||||
{editingCollision ? (
|
||||
<div className="collision-mode-hint">
|
||||
<Box size={32} />
|
||||
<span className="collision-mode-title">碰撞编辑模式</span>
|
||||
<span className="collision-mode-desc">使用画笔绘制碰撞区域</span>
|
||||
<span className="collision-mode-desc">使用橡皮擦清除碰撞</span>
|
||||
</div>
|
||||
) : tilesetImageUrl ? (
|
||||
<TilesetPreview
|
||||
imageUrl={tilesetImageUrl}
|
||||
tileWidth={tileWidth}
|
||||
tileHeight={tileHeight}
|
||||
columns={tilesetColumns}
|
||||
rows={tilesetRows}
|
||||
tileset={activeTileset}
|
||||
animatedTileIds={animatedTileIds}
|
||||
onEditAnimation={handleEditAnimation}
|
||||
/>
|
||||
) : (
|
||||
<div className="tileset-empty-hint">
|
||||
@@ -167,6 +268,18 @@ export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
||||
已选择: {selectedTiles.width}×{selectedTiles.height}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Animation Editor */}
|
||||
{editingAnimationTileId !== null && activeTileset && tilesetImage && (
|
||||
<TileAnimationEditor
|
||||
tileId={editingAnimationTileId}
|
||||
tileset={activeTileset}
|
||||
tilesetImage={tilesetImage}
|
||||
animation={editingTileAnimation}
|
||||
onAnimationChange={handleAnimationChange}
|
||||
onClose={handleCloseAnimationEditor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Tilemap 详情面板 - 右侧分组属性面板
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
Search,
|
||||
Settings,
|
||||
Eye,
|
||||
EyeOff
|
||||
EyeOff,
|
||||
FileBox
|
||||
} from 'lucide-react';
|
||||
import { useTilemapEditorStore, type LayerState } from '../../stores/TilemapEditorStore';
|
||||
import type { TilemapComponent } from '@esengine/tilemap';
|
||||
@@ -26,8 +27,11 @@ interface TilemapDetailsPanelProps {
|
||||
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
|
||||
@@ -123,6 +127,40 @@ const NumberProperty: React.FC<NumberPropertyProps> = ({
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
// 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<SliderPropertyProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
max = 1,
|
||||
step = 0.01
|
||||
}) => (
|
||||
<PropertyRow label={label}>
|
||||
<div className="slider-wrapper">
|
||||
<input
|
||||
type="range"
|
||||
className="property-slider"
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
/>
|
||||
<span className="slider-value">{Math.round(value * 100)}%</span>
|
||||
</div>
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
// Color property - unified style matching PropertyInspector
|
||||
interface ColorPropertyProps {
|
||||
label: string;
|
||||
@@ -175,13 +213,85 @@ const ColorProperty: React.FC<ColorPropertyProps> = ({ label, value, onChange })
|
||||
);
|
||||
};
|
||||
|
||||
// Material field - AssetField-like style for material selection
|
||||
interface MaterialFieldProps {
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
onSelect: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
const MaterialField: React.FC<MaterialFieldProps> = ({ label, value, onSelect, onClear }) => {
|
||||
const getFileName = (path: string) => {
|
||||
const parts = path.split(/[\\/]/);
|
||||
return parts[parts.length - 1].replace('.mat', '').replace('.json', '');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="material-field">
|
||||
<label className="material-field__label">{label}</label>
|
||||
<div className="material-field__content">
|
||||
{/* Thumbnail */}
|
||||
<div className="material-field__thumbnail">
|
||||
<FileBox size={18} className="material-field__thumbnail-icon" />
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="material-field__right">
|
||||
{/* Dropdown */}
|
||||
<div
|
||||
className={`material-field__dropdown ${value ? 'has-value' : ''}`}
|
||||
onClick={onSelect}
|
||||
title={value || '点击选择材质'}
|
||||
>
|
||||
<span className="material-field__value">
|
||||
{value ? getFileName(value) : '默认材质'}
|
||||
</span>
|
||||
<ChevronDown size={12} className="material-field__dropdown-arrow" />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="material-field__actions">
|
||||
{value && (
|
||||
<>
|
||||
<button
|
||||
className="material-field__btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(value);
|
||||
}}
|
||||
title="复制路径"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="material-field__btn material-field__btn--clear"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClear();
|
||||
}}
|
||||
title="清除"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
||||
tilemap,
|
||||
onAddLayer,
|
||||
onRemoveLayer,
|
||||
onMoveLayer,
|
||||
onDuplicateLayer,
|
||||
onTilemapChange,
|
||||
onOpenAssetPicker
|
||||
onOpenAssetPicker,
|
||||
onSelectLayerMaterial
|
||||
}) => {
|
||||
const {
|
||||
layers,
|
||||
@@ -189,19 +299,24 @@ export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
||||
setCurrentLayer,
|
||||
toggleLayerVisibility,
|
||||
setLayerOpacity,
|
||||
setLayerColor,
|
||||
setLayerHiddenInGame,
|
||||
renameLayer,
|
||||
showCollision,
|
||||
setShowCollision
|
||||
} = useTilemapEditorStore();
|
||||
|
||||
// Layer properties state - synced with store's visibility
|
||||
// Layer name editing state
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
|
||||
// Layer properties state - synced with store
|
||||
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;
|
||||
@@ -230,10 +345,74 @@ export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
||||
}
|
||||
}, [toggleLayerVisibility, tilemap, onTilemapChange]);
|
||||
|
||||
// Colors
|
||||
// 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 [_layerGridColor, _setLayerGridColor] = useState('#00ff00');
|
||||
|
||||
const handleLayerSelect = useCallback((index: number) => {
|
||||
setCurrentLayer(index);
|
||||
@@ -348,7 +527,8 @@ export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
||||
</button>
|
||||
<button
|
||||
className="layer-action-btn"
|
||||
title="复制"
|
||||
onClick={() => onDuplicateLayer(currentLayer)}
|
||||
title="复制图层"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
@@ -365,8 +545,26 @@ export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
||||
|
||||
{/* Selected Layer Section */}
|
||||
<Section title="选定层">
|
||||
<PropertyRow label="">
|
||||
<span className="selected-layer-name">{selectedLayer?.name || '图层 1'}</span>
|
||||
<PropertyRow label="名称">
|
||||
{isEditingName ? (
|
||||
<input
|
||||
type="text"
|
||||
className="layer-name-input"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onBlur={handleFinishEditName}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="selected-layer-name editable"
|
||||
onDoubleClick={handleStartEditName}
|
||||
title="双击编辑名称"
|
||||
>
|
||||
{selectedLayer?.name || '图层 1'}
|
||||
</span>
|
||||
)}
|
||||
</PropertyRow>
|
||||
<ToggleProperty
|
||||
label="编辑器中隐藏"
|
||||
@@ -375,8 +573,16 @@ export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
||||
/>
|
||||
<ToggleProperty
|
||||
label="游戏中隐藏"
|
||||
checked={hiddenInGame}
|
||||
onChange={setHiddenInGame}
|
||||
checked={selectedLayer?.hiddenInGame ?? false}
|
||||
onChange={handleHiddenInGameChange}
|
||||
/>
|
||||
<SliderProperty
|
||||
label="图层透明度"
|
||||
value={selectedLayer?.opacity ?? 1}
|
||||
onChange={handleLayerOpacityChange}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
<ToggleProperty
|
||||
label="图层碰撞"
|
||||
@@ -411,8 +617,8 @@ export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
||||
)}
|
||||
<ColorProperty
|
||||
label="图层颜色"
|
||||
value={layerColor}
|
||||
onChange={setLayerColor}
|
||||
value={selectedLayer?.color ?? '#ffffff'}
|
||||
onChange={handleLayerColorChange}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
@@ -457,13 +663,18 @@ export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
||||
</Section>
|
||||
|
||||
{/* Material Section */}
|
||||
<Section title="材质" defaultOpen={false}>
|
||||
<PropertyRow label="材质">
|
||||
<button className="asset-dropdown">
|
||||
<span>Masked</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
</PropertyRow>
|
||||
<Section title="图层材质" defaultOpen={true}>
|
||||
<div className="material-section-content">
|
||||
<MaterialField
|
||||
label={`${selectedLayer?.name || '图层'} 材质`}
|
||||
value={tilemap.getLayerMaterial(currentLayer)}
|
||||
onSelect={() => onSelectLayerMaterial?.(currentLayer)}
|
||||
onClear={() => {
|
||||
tilemap.setLayerMaterial(currentLayer, '');
|
||||
onTilemapChange();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Advanced Section */}
|
||||
|
||||
@@ -11,31 +11,19 @@ import {
|
||||
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
|
||||
Map
|
||||
} from 'lucide-react';
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { MessageHub, ProjectService, IFileSystemService, type IFileSystem, IDialogService, type IDialog } from '@esengine/editor-core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub, ProjectService, IFileSystemService, type IFileSystem, 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';
|
||||
@@ -578,7 +566,7 @@ const PanelDivider: React.FC<PanelDividerProps> = ({ onDrag, direction }) => {
|
||||
|
||||
export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageHub: propMessageHub }) => {
|
||||
const [tilemap, setTilemap] = useState<TilemapComponent | null>(null);
|
||||
const [entity, setEntity] = useState<Entity | null>(null);
|
||||
const [_entity, setEntity] = useState<unknown>(null);
|
||||
|
||||
// Panel widths for resizable layout - smaller defaults to give viewport more space
|
||||
const [leftPanelWidth, setLeftPanelWidth] = useState(180);
|
||||
@@ -596,12 +584,9 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
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');
|
||||
// Material picker state
|
||||
const [showMaterialPicker, setShowMaterialPicker] = useState(false);
|
||||
const [materialPickerLayerIndex, setMaterialPickerLayerIndex] = useState(0);
|
||||
|
||||
const messageHub = propMessageHub || Core.services.resolve(MessageHub);
|
||||
|
||||
@@ -609,12 +594,9 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
entityId,
|
||||
pendingFilePath,
|
||||
currentFilePath,
|
||||
currentTool,
|
||||
zoom,
|
||||
showGrid,
|
||||
showCollision,
|
||||
editingCollision,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
tilesetImageUrl,
|
||||
tilesetColumns,
|
||||
@@ -622,15 +604,16 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
setEntityId,
|
||||
setPendingFilePath,
|
||||
setCurrentFilePath,
|
||||
setCurrentTool,
|
||||
setZoom,
|
||||
setShowGrid,
|
||||
setShowCollision,
|
||||
setEditingCollision,
|
||||
setPan,
|
||||
setTileset,
|
||||
setLayers,
|
||||
setCurrentLayer
|
||||
setCurrentLayer,
|
||||
currentLayer,
|
||||
undo,
|
||||
redo
|
||||
} = useTilemapEditorStore();
|
||||
|
||||
// Load tileset from component (defined early for use in effects)
|
||||
@@ -721,7 +704,9 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
name: layer.name,
|
||||
visible: layer.visible,
|
||||
locked: false,
|
||||
opacity: layer.opacity
|
||||
opacity: layer.opacity,
|
||||
color: layer.color ?? '#ffffff',
|
||||
hiddenInGame: layer.hiddenInGame ?? false
|
||||
}));
|
||||
setLayers(layerStates);
|
||||
setCurrentLayer(0);
|
||||
@@ -788,7 +773,9 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
name: layer.name,
|
||||
visible: layer.visible,
|
||||
locked: false,
|
||||
opacity: layer.opacity
|
||||
opacity: layer.opacity,
|
||||
color: layer.color ?? '#ffffff',
|
||||
hiddenInGame: layer.hiddenInGame ?? false
|
||||
}));
|
||||
setLayers(layerStates);
|
||||
setCurrentLayer(0);
|
||||
@@ -800,7 +787,6 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
|
||||
const unsubscribeModified = messageHub.subscribe('scene:modified', () => {
|
||||
loadTilesetFromComponent(tilemap);
|
||||
setTilemapKey(`${tilemap.width}-${tilemap.height}-${Date.now()}`);
|
||||
});
|
||||
|
||||
const unsubscribeRestored = messageHub.subscribe('scene:restored', () => {
|
||||
@@ -840,16 +826,61 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
messageHub?.publish('scene:modified', {});
|
||||
}, [messageHub]);
|
||||
|
||||
// Handle tile animation change from animation editor
|
||||
const handleTileAnimationChange = useCallback((tileId: number, animation: import('@esengine/tilemap').ITileAnimation | null) => {
|
||||
if (!tilemap) return;
|
||||
|
||||
const tilesetRef = tilemap.tilesets[activeTilesetIndex];
|
||||
if (!tilesetRef?.data) return;
|
||||
|
||||
// Ensure tiles array exists
|
||||
if (!tilesetRef.data.tiles) {
|
||||
tilesetRef.data.tiles = [];
|
||||
}
|
||||
|
||||
// Find or create tile metadata
|
||||
let tileMetadata = tilesetRef.data.tiles.find(t => t.id === tileId);
|
||||
if (!tileMetadata) {
|
||||
tileMetadata = { id: tileId };
|
||||
tilesetRef.data.tiles.push(tileMetadata);
|
||||
}
|
||||
|
||||
// Update animation
|
||||
if (animation) {
|
||||
tileMetadata.animation = animation;
|
||||
} else {
|
||||
delete tileMetadata.animation;
|
||||
// Remove empty tile metadata
|
||||
if (!tileMetadata.type && !tileMetadata.properties) {
|
||||
const index = tilesetRef.data.tiles.indexOf(tileMetadata);
|
||||
if (index >= 0) {
|
||||
tilesetRef.data.tiles.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleTilemapChange();
|
||||
}, [tilemap, activeTilesetIndex, handleTilemapChange]);
|
||||
|
||||
// Get active tileset data for animation editor
|
||||
const activeTilesetData = tilemap?.tilesets[activeTilesetIndex]?.data;
|
||||
|
||||
const handleSaveTilemap = useCallback(async () => {
|
||||
if (!tilemap || !entity) return;
|
||||
if (!tilemap) return;
|
||||
|
||||
try {
|
||||
const tilemapData = tilemap.exportToData();
|
||||
const jsonContent = JSON.stringify(tilemapData, null, 2);
|
||||
|
||||
const tilemapAssetPath = tilemap.tilemapAssetGuid;
|
||||
// Use tilemapAssetGuid or currentFilePath for file-based editing
|
||||
const tilemapAssetPath = tilemap.tilemapAssetGuid || currentFilePath;
|
||||
if (!tilemapAssetPath) {
|
||||
console.warn('Tilemap asset path not set');
|
||||
messageHub?.publish('notification:show', {
|
||||
type: 'warning',
|
||||
message: 'Cannot save: No tilemap file associated. Please set a tilemap asset path first.',
|
||||
duration: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -885,21 +916,65 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
}, [tilemap, entity, messageHub]);
|
||||
}, [tilemap, currentFilePath, messageHub]);
|
||||
|
||||
// Handle undo action
|
||||
const handleUndo = useCallback(() => {
|
||||
if (!tilemap) return;
|
||||
|
||||
const previousData = undo();
|
||||
if (previousData) {
|
||||
tilemap.setLayerData(currentLayer, previousData);
|
||||
handleTilemapChange();
|
||||
}
|
||||
}, [tilemap, currentLayer, undo, handleTilemapChange]);
|
||||
|
||||
// Handle redo action
|
||||
const handleRedo = useCallback(() => {
|
||||
if (!tilemap) return;
|
||||
|
||||
const nextData = redo();
|
||||
if (nextData) {
|
||||
tilemap.setLayerData(currentLayer, nextData);
|
||||
handleTilemapChange();
|
||||
}
|
||||
}, [tilemap, currentLayer, redo, handleTilemapChange]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSaveTilemap();
|
||||
// Check if Ctrl or Cmd is pressed
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 's':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSaveTilemap();
|
||||
break;
|
||||
case 'z':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.shiftKey) {
|
||||
// Ctrl+Shift+Z = Redo
|
||||
handleRedo();
|
||||
} else {
|
||||
// Ctrl+Z = Undo
|
||||
handleUndo();
|
||||
}
|
||||
break;
|
||||
case 'y':
|
||||
// Ctrl+Y = Redo (Windows style)
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleRedo();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
}, [handleSaveTilemap]);
|
||||
}, [handleSaveTilemap, handleUndo, handleRedo]);
|
||||
|
||||
const handleZoomIn = () => setZoom(Math.min(10, zoom * 1.2));
|
||||
const handleZoomOut = () => setZoom(Math.max(0.1, zoom / 1.2));
|
||||
@@ -908,8 +983,8 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
setPan(0, 0);
|
||||
};
|
||||
|
||||
// 退出全屏模式
|
||||
const handleExitFullscreen = useCallback(() => {
|
||||
// 退出全屏模式 (reserved for future use)
|
||||
const _handleExitFullscreen = useCallback(() => {
|
||||
messageHub?.publish('editor:fullscreen', { fullscreen: false });
|
||||
}, [messageHub]);
|
||||
|
||||
@@ -922,7 +997,9 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
name: layer.name,
|
||||
visible: layer.visible,
|
||||
locked: false,
|
||||
opacity: layer.opacity
|
||||
opacity: layer.opacity,
|
||||
color: layer.color ?? '#ffffff',
|
||||
hiddenInGame: layer.hiddenInGame ?? false
|
||||
}));
|
||||
setLayers(layerStates);
|
||||
setCurrentLayer(tilemap.layers.length - 1);
|
||||
@@ -938,7 +1015,9 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
name: layer.name,
|
||||
visible: layer.visible,
|
||||
locked: false,
|
||||
opacity: layer.opacity
|
||||
opacity: layer.opacity,
|
||||
color: layer.color ?? '#ffffff',
|
||||
hiddenInGame: layer.hiddenInGame ?? false
|
||||
}));
|
||||
setLayers(layerStates);
|
||||
const { currentLayer } = useTilemapEditorStore.getState();
|
||||
@@ -958,7 +1037,9 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
name: layer.name,
|
||||
visible: layer.visible,
|
||||
locked: false,
|
||||
opacity: layer.opacity
|
||||
opacity: layer.opacity,
|
||||
color: layer.color ?? '#ffffff',
|
||||
hiddenInGame: layer.hiddenInGame ?? false
|
||||
}));
|
||||
setLayers(layerStates);
|
||||
setCurrentLayer(toIndex);
|
||||
@@ -966,6 +1047,26 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
handleTilemapChange();
|
||||
}, [tilemap, setLayers, setCurrentLayer, handleTilemapChange]);
|
||||
|
||||
const handleDuplicateLayer = useCallback((index: number) => {
|
||||
if (!tilemap) return;
|
||||
const newLayer = tilemap.duplicateLayer(index);
|
||||
if (!newLayer) return;
|
||||
|
||||
const layerStates: LayerState[] = tilemap.layers.map((layer) => ({
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
visible: layer.visible,
|
||||
locked: false,
|
||||
opacity: layer.opacity,
|
||||
color: layer.color ?? '#ffffff',
|
||||
hiddenInGame: layer.hiddenInGame ?? false
|
||||
}));
|
||||
setLayers(layerStates);
|
||||
setCurrentLayer(index + 1); // Select the new duplicated layer
|
||||
tilemap.renderDirty = true;
|
||||
handleTilemapChange();
|
||||
}, [tilemap, setLayers, setCurrentLayer, handleTilemapChange]);
|
||||
|
||||
// Tileset operations
|
||||
const handleAddTileset = useCallback(() => {
|
||||
if (!tilemap) return;
|
||||
@@ -995,6 +1096,18 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
handleTilemapChange();
|
||||
}, [tilemap, handleTilemapChange]);
|
||||
|
||||
// Layer material selection
|
||||
const handleSelectLayerMaterial = useCallback((layerIndex: number) => {
|
||||
setMaterialPickerLayerIndex(layerIndex);
|
||||
setShowMaterialPicker(true);
|
||||
}, []);
|
||||
|
||||
const handleMaterialSelected = useCallback((path: string) => {
|
||||
if (!tilemap) return;
|
||||
tilemap.setLayerMaterial(materialPickerLayerIndex, path);
|
||||
handleTilemapChange();
|
||||
}, [tilemap, materialPickerLayerIndex, handleTilemapChange]);
|
||||
|
||||
// Get tileset list
|
||||
const tilesetOptions = tilemap?.tilesets.map((t, i) => ({
|
||||
name: t.data?.name || `Tileset ${i + 1}`,
|
||||
@@ -1025,8 +1138,11 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
<TileSetSelectorPanel
|
||||
tilesets={tilesetOptions}
|
||||
activeTilesetIndex={activeTilesetIndex}
|
||||
activeTileset={activeTilesetData}
|
||||
tilesetImage={tilesetImage}
|
||||
onTilesetChange={handleTilesetChange}
|
||||
onAddTileset={handleAddTileset}
|
||||
onTileAnimationChange={handleTileAnimationChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1038,69 +1154,6 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
{/* 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' : ''}`}
|
||||
@@ -1109,23 +1162,24 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
>
|
||||
<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
|
||||
className={`viewport-btn icon ${showCollision ? 'active' : ''}`}
|
||||
onClick={() => setShowCollision(!showCollision)}
|
||||
title="显示碰撞"
|
||||
>
|
||||
<Box size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="viewport-toolbar-center">
|
||||
<button className="viewport-btn" onClick={handleSaveTilemap} title="保存 (Ctrl+S)">
|
||||
<Save size={14} />
|
||||
<span>保存</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="viewport-toolbar-right">
|
||||
{/* Zoom controls */}
|
||||
<div className="viewport-btn-group">
|
||||
<button className="viewport-btn icon" onClick={handleZoomOut} title="缩小">
|
||||
<ZoomOut size={14} />
|
||||
@@ -1158,7 +1212,7 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
<div className="info-item">近似大小: {tilemap.width * tilemap.tileWidth}x{tilemap.height * tilemap.tileHeight}</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
{/* Viewport */}
|
||||
<div className="viewport-canvas-container">
|
||||
<TilemapCanvas
|
||||
key={tilemapKey}
|
||||
@@ -1190,8 +1244,10 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
onAddLayer={handleAddLayer}
|
||||
onRemoveLayer={handleRemoveLayer}
|
||||
onMoveLayer={handleMoveLayer}
|
||||
onDuplicateLayer={handleDuplicateLayer}
|
||||
onTilemapChange={handleTilemapChange}
|
||||
onOpenAssetPicker={() => setShowAssetPicker(true)}
|
||||
onSelectLayerMaterial={handleSelectLayerMaterial}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1204,6 +1260,15 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
fileExtensions={['.png', '.jpg', '.jpeg', '.webp']}
|
||||
/>
|
||||
|
||||
{/* Material Picker Dialog */}
|
||||
<AssetPickerDialog
|
||||
isOpen={showMaterialPicker}
|
||||
onClose={() => setShowMaterialPicker(false)}
|
||||
onSelect={handleMaterialSelected}
|
||||
title="选择图层材质"
|
||||
fileExtensions={['.mat', '.mat.json']}
|
||||
/>
|
||||
|
||||
<ResizeMapDialog
|
||||
isOpen={showResizeDialog}
|
||||
onClose={() => setShowResizeDialog(false)}
|
||||
|
||||
Reference in New Issue
Block a user