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:
YHH
2025-12-03 22:15:22 +08:00
committed by GitHub
parent caf7622aa0
commit 63f006ab62
496 changed files with 77601 additions and 4067 deletions

View File

@@ -4,11 +4,14 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import type { TilemapComponent } from '@esengine/tilemap';
import { tilemapAnimationSystem } from '@esengine/tilemap';
import { useTilemapEditorStore } from '../stores/TilemapEditorStore';
import type { ITilemapTool, ToolContext } from '../tools/ITilemapTool';
import { BrushTool } from '../tools/BrushTool';
import { EraserTool } from '../tools/EraserTool';
import { FillTool } from '../tools/FillTool';
import { RectangleTool } from '../tools/RectangleTool';
import { SelectTool } from '../tools/SelectTool';
interface TilemapCanvasProps {
tilemap: TilemapComponent;
@@ -20,6 +23,8 @@ const tools: Record<string, ITilemapTool> = {
brush: new BrushTool(),
eraser: new EraserTool(),
fill: new FillTool(),
rectangle: new RectangleTool(),
select: new SelectTool(),
};
export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
@@ -57,9 +62,12 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
const layersKey = layers.map(l => `${l.visible}-${l.opacity}`).join(',');
const [isPanning, setIsPanning] = useState(false);
const [lastPanPos, setLastPanPos] = useState({ x: 0, y: 0 });
const lastPanPosRef = useRef({ x: 0, y: 0 });
const [mousePos, setMousePos] = useState<{ tileX: number; tileY: number } | null>(null);
const [spacePressed, setSpacePressed] = useState(false);
const [animationTime, setAnimationTime] = useState(0);
const lastFrameTimeRef = useRef<number>(0);
const animationFrameRef = useRef<number | null>(null);
// Get canvas size
const canvasWidth = tilemap.width * tileWidth;
@@ -73,11 +81,14 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
// Clear
ctx.fillStyle = '#2d2d2d';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.scale(dpr, dpr);
ctx.translate(panX, panY);
ctx.scale(zoom, zoom);
@@ -104,9 +115,16 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
for (let x = 0; x < tilemap.width; x++) {
const tileIndex = tilemap.getTile(layerIndex, x, y);
if (tileIndex > 0) {
// Get the tileset index for this tile (assuming single tileset for now)
// tileIndex is 1-based (0 = empty), so tileId = tileIndex - 1
const tileId = tileIndex - 1;
// Get current animation frame tile ID (returns original if not animated)
const displayTileId = tilemapAnimationSystem.getCurrentTileId(0, tileId);
// Calculate source position in tileset
const srcX = ((tileIndex - 1) % tilesetColumns) * tileWidth;
const srcY = Math.floor((tileIndex - 1) / tilesetColumns) * tileHeight;
const srcX = (displayTileId % tilesetColumns) * tileWidth;
const srcY = Math.floor(displayTileId / tilesetColumns) * tileHeight;
// Only draw if tile is within tileset bounds
if (srcX + tileWidth <= tilesetImage.width && srcY + tileHeight <= tilesetImage.height) {
@@ -182,7 +200,7 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
}
ctx.restore();
}, [tilemap, tilesetImage, zoom, panX, panY, showGrid, showCollision, mousePos, currentTool, selectedTiles, brushSize, currentLayer, layerLocked, editingCollision, tileWidth, tileHeight, tilesetColumns, canvasWidth, canvasHeight, layersKey]);
}, [tilemap, tilesetImage, zoom, panX, panY, showGrid, showCollision, mousePos, currentTool, selectedTiles, brushSize, currentLayer, layerLocked, editingCollision, tileWidth, tileHeight, tilesetColumns, canvasWidth, canvasHeight, layersKey, animationTime]);
// Update canvas size
useEffect(() => {
@@ -199,11 +217,16 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
const dpr = window.devicePixelRatio || 1;
const newWidth = container.clientWidth;
const newHeight = container.clientHeight;
if (canvas.width !== newWidth || canvas.height !== newHeight) {
canvas.width = newWidth;
canvas.height = newHeight;
const scaledWidth = Math.floor(newWidth * dpr);
const scaledHeight = Math.floor(newHeight * dpr);
if (canvas.width !== scaledWidth || canvas.height !== scaledHeight) {
canvas.width = scaledWidth;
canvas.height = scaledHeight;
canvas.style.width = `${newWidth}px`;
canvas.style.height = `${newHeight}px`;
draw();
}
rafId = null;
@@ -223,6 +246,44 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
draw();
}, [draw]);
// Register tileset animations when tilemap changes
useEffect(() => {
tilemapAnimationSystem.clear();
for (let i = 0; i < tilemap.tilesets.length; i++) {
const tilesetRef = tilemap.tilesets[i];
if (tilesetRef.data) {
tilemapAnimationSystem.registerTileset(i, tilesetRef.data);
}
}
return () => {
tilemapAnimationSystem.clear();
};
}, [tilemap]);
// Animation loop for animated tiles
useEffect(() => {
const animate = (time: number) => {
if (lastFrameTimeRef.current === 0) {
lastFrameTimeRef.current = time;
}
const deltaTime = time - lastFrameTimeRef.current;
lastFrameTimeRef.current = time;
tilemapAnimationSystem.update(deltaTime);
setAnimationTime(time);
animationFrameRef.current = requestAnimationFrame(animate);
};
animationFrameRef.current = requestAnimationFrame(animate);
return () => {
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, []);
// Center view on first mount
useEffect(() => {
const container = containerRef.current;
@@ -288,7 +349,7 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
// Middle mouse button, Alt+left click, or Space+left click for panning
if (e.button === 1 || (e.button === 0 && (e.altKey || spacePressed))) {
setIsPanning(true);
setLastPanPos({ x: e.clientX, y: e.clientY });
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
return;
}
@@ -313,7 +374,7 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
};
tool.onMouseDown(tileX, tileY, toolContext);
onTilemapChange?.();
draw();
// draw() 由 useEffect 统一处理,避免重复绘制导致闪烁
}
};
@@ -326,10 +387,11 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
// Handle panning
if (isPanning) {
const dx = e.clientX - lastPanPos.x;
const dy = e.clientY - lastPanPos.y;
setPan(panX + dx, panY + dy);
setLastPanPos({ x: e.clientX, y: e.clientY });
const dx = e.clientX - lastPanPosRef.current.x;
const dy = e.clientY - lastPanPosRef.current.y;
const state = useTilemapEditorStore.getState();
setPan(state.panX + dx, state.panY + dy);
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
return;
}
@@ -354,8 +416,7 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
onTilemapChange?.();
}
}
draw();
// draw() 由 setMousePos 触发的 useEffect 统一处理
};
const handleMouseUp = (e: React.MouseEvent) => {
@@ -389,7 +450,7 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
const handleMouseLeave = () => {
setMousePos(null);
draw();
// draw() 由 setMousePos 触发的 useEffect 统一处理
};
const handleWheel = (e: React.WheelEvent) => {

View File

@@ -0,0 +1,388 @@
/**
* Tilemap Viewport - Engine-based rendering for tilemap editor
* Tilemap 视口 - 基于引擎的瓦片地图编辑器渲染
*
* Uses the same rendering pipeline as the main editor viewport.
* 使用与主编辑器视口相同的渲染管线。
*/
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Core } from '@esengine/ecs-framework';
import { IViewportService_ID, type IViewportService } from '@esengine/editor-core';
import type { TilemapComponent } from '@esengine/tilemap';
import { useTilemapEditorStore } from '../stores/TilemapEditorStore';
import type { ITilemapTool, ToolContext } from '../tools/ITilemapTool';
import { BrushTool } from '../tools/BrushTool';
import { EraserTool } from '../tools/EraserTool';
import { FillTool } from '../tools/FillTool';
interface TilemapViewportProps {
tilemap: TilemapComponent;
onTilemapChange?: () => void;
}
const VIEWPORT_ID = 'tilemap-editor-viewport';
const CANVAS_ID = 'tilemap-editor-canvas';
const tools: Record<string, ITilemapTool> = {
brush: new BrushTool(),
eraser: new EraserTool(),
fill: new FillTool(),
};
export const TilemapViewport: React.FC<TilemapViewportProps> = ({
tilemap,
onTilemapChange,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const viewportServiceRef = useRef<IViewportService | null>(null);
const registeredRef = useRef(false);
const {
currentTool,
zoom,
panX,
panY,
showGrid,
showCollision: _showCollision,
selectedTiles,
brushSize,
currentLayer,
editingCollision,
tileWidth,
tileHeight,
layers,
setPan,
setZoom,
pushUndo,
} = useTilemapEditorStore();
// Get layer locked state
const layerLocked = layers[currentLayer]?.locked ?? false;
const [isPanning, setIsPanning] = useState(false);
const [lastPanPos, setLastPanPos] = useState({ x: 0, y: 0 });
const [_mousePos, setMousePos] = useState<{ tileX: number; tileY: number } | null>(null);
const [spacePressed, setSpacePressed] = useState(false);
// Get canvas size (reserved for future virtual scrolling)
const _canvasWidth = tilemap.width * tileWidth;
const _canvasHeight = tilemap.height * tileHeight;
// Initialize viewport service
useEffect(() => {
const service = Core.services.tryResolve<IViewportService>(IViewportService_ID);
viewportServiceRef.current = service ?? null;
if (!service) {
console.warn('[TilemapViewport] ViewportService not available');
}
}, []);
// Register viewport when canvas is ready
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
const service = viewportServiceRef.current;
if (!canvas || !container || !service) return;
// Wait for engine to be initialized
if (!service.isInitialized()) {
const checkInit = setInterval(() => {
if (service.isInitialized() && !registeredRef.current) {
clearInterval(checkInit);
setupViewport();
}
}, 100);
return () => clearInterval(checkInit);
}
setupViewport();
function setupViewport() {
if (registeredRef.current || !canvas || !container || !service) return;
// Set canvas size
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
canvas.width = Math.floor(rect.width * dpr);
canvas.height = Math.floor(rect.height * dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
// Register viewport
service.registerViewport(VIEWPORT_ID, CANVAS_ID);
service.setViewportConfig(VIEWPORT_ID, showGrid, false); // No gizmos in tilemap editor
service.resizeViewport(VIEWPORT_ID, canvas.width, canvas.height);
registeredRef.current = true;
}
return () => {
if (registeredRef.current && service) {
service.unregisterViewport(VIEWPORT_ID);
registeredRef.current = false;
}
};
}, [showGrid]);
// Handle resize
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
const service = viewportServiceRef.current;
if (!container || !canvas || !service || !registeredRef.current) return;
let rafId: number | null = null;
const resizeObserver = new ResizeObserver(() => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
const newWidth = Math.floor(rect.width * dpr);
const newHeight = Math.floor(rect.height * dpr);
if (canvas.width !== newWidth || canvas.height !== newHeight) {
canvas.width = newWidth;
canvas.height = newHeight;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
service.resizeViewport(VIEWPORT_ID, newWidth, newHeight);
}
rafId = null;
});
});
resizeObserver.observe(container);
return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
resizeObserver.disconnect();
};
}, []);
// Update camera when pan/zoom changes
useEffect(() => {
const service = viewportServiceRef.current;
if (!service || !registeredRef.current) return;
// Convert pan to camera position
// In engine, camera position is the center of view
const canvas = canvasRef.current;
if (!canvas) return;
const centerX = (canvas.width / 2 - panX) / zoom;
const centerY = (canvas.height / 2 - panY) / zoom;
service.setViewportCamera(VIEWPORT_ID, {
x: centerX,
y: -centerY, // Y is flipped
zoom: zoom
});
}, [panX, panY, zoom]);
// Update grid visibility
useEffect(() => {
const service = viewportServiceRef.current;
if (!service || !registeredRef.current) return;
service.setViewportConfig(VIEWPORT_ID, showGrid, false);
}, [showGrid]);
// Space key for panning mode
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space' && !e.repeat) {
e.preventDefault();
setSpacePressed(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space') {
setSpacePressed(false);
setIsPanning(false);
}
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
// Convert screen coordinates to tile coordinates
const screenToTile = useCallback((screenX: number, screenY: number) => {
const x = (screenX - panX) / zoom;
const y = (screenY - panY) / zoom;
return {
tileX: Math.floor(x / tileWidth),
tileY: Math.floor(y / tileHeight),
};
}, [panX, panY, zoom, tileWidth, tileHeight]);
// Mouse handlers
const handleMouseDown = (e: React.MouseEvent) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Middle mouse button, Alt+left click, or Space+left click for panning
if (e.button === 1 || (e.button === 0 && (e.altKey || spacePressed))) {
setIsPanning(true);
setLastPanPos({ x: e.clientX, y: e.clientY });
return;
}
// Save undo state
const layerData = tilemap.getLayerData(currentLayer);
if (layerData) {
pushUndo(layerData.slice());
}
const { tileX, tileY } = screenToTile(x, y);
const tool = tools[currentTool];
if (tool) {
const toolContext: ToolContext = {
tilemap,
selectedTiles,
currentLayer,
layerLocked,
brushSize,
editingCollision,
tileWidth,
tileHeight,
};
tool.onMouseDown(tileX, tileY, toolContext);
onTilemapChange?.();
}
};
const handleMouseMove = (e: React.MouseEvent) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Handle panning
if (isPanning) {
const dx = e.clientX - lastPanPos.x;
const dy = e.clientY - lastPanPos.y;
const state = useTilemapEditorStore.getState();
setPan(state.panX + dx, state.panY + dy);
setLastPanPos({ x: e.clientX, y: e.clientY });
return;
}
const { tileX, tileY } = screenToTile(x, y);
setMousePos({ tileX, tileY });
// Handle tool drag
if (e.buttons === 1) {
const tool = tools[currentTool];
if (tool) {
const toolContext: ToolContext = {
tilemap,
selectedTiles,
currentLayer,
layerLocked,
brushSize,
editingCollision,
tileWidth,
tileHeight,
};
tool.onMouseMove(tileX, tileY, toolContext);
onTilemapChange?.();
}
}
};
const handleMouseUp = (e: React.MouseEvent) => {
if (isPanning) {
setIsPanning(false);
return;
}
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const { tileX, tileY } = screenToTile(x, y);
const tool = tools[currentTool];
if (tool) {
const toolContext: ToolContext = {
tilemap,
selectedTiles,
currentLayer,
layerLocked,
brushSize,
editingCollision,
tileWidth,
tileHeight,
};
tool.onMouseUp(tileX, tileY, toolContext);
}
};
const handleMouseLeave = () => {
setMousePos(null);
};
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.max(0.1, Math.min(10, zoom * delta));
// Zoom towards mouse position
const rect = canvasRef.current?.getBoundingClientRect();
if (rect) {
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const newPanX = mouseX - (mouseX - panX) * (newZoom / zoom);
const newPanY = mouseY - (mouseY - panY) * (newZoom / zoom);
setPan(newPanX, newPanY);
}
setZoom(newZoom);
};
// Determine cursor style
const getCursor = () => {
if (isPanning) return 'grabbing';
if (spacePressed) return 'grab';
return tools[currentTool]?.cursor || 'crosshair';
};
return (
<div ref={containerRef} className="tilemap-canvas-container">
<canvas
ref={canvasRef}
id={CANVAS_ID}
className="tilemap-canvas"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onWheel={handleWheel}
onContextMenu={(e) => e.preventDefault()}
style={{ cursor: getCursor() }}
/>
</div>
);
};

View File

@@ -4,6 +4,7 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { useTilemapEditorStore, type TileSelection } from '../stores/TilemapEditorStore';
import type { ITilesetData, ITileAnimation } from '@esengine/tilemap';
interface TilesetPreviewProps {
imageUrl: string;
@@ -11,7 +12,10 @@ interface TilesetPreviewProps {
tileHeight: number;
columns: number;
rows: number;
tileset?: ITilesetData;
animatedTileIds?: Set<number>;
onSelectionChange?: (selection: TileSelection) => void;
onEditAnimation?: (tileId: number) => void;
}
export const TilesetPreview: React.FC<TilesetPreviewProps> = ({
@@ -20,7 +24,10 @@ export const TilesetPreview: React.FC<TilesetPreviewProps> = ({
tileHeight,
columns,
rows,
tileset,
animatedTileIds,
onSelectionChange,
onEditAnimation,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -29,6 +36,7 @@ export const TilesetPreview: React.FC<TilesetPreviewProps> = ({
const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
const [selectionEnd, setSelectionEnd] = useState<{ x: number; y: number } | null>(null);
const [zoom, setZoom] = useState(1);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tileId: number } | null>(null);
const selectedTiles = useTilemapEditorStore(state => state.selectedTiles);
const setSelectedTiles = useTilemapEditorStore(state => state.setSelectedTiles);
@@ -101,7 +109,24 @@ export const TilesetPreview: React.FC<TilesetPreviewProps> = ({
selectedTiles.height * tileHeight - 2
);
}
}, [image, columns, rows, tileWidth, tileHeight, selectedTiles, isSelecting, selectionStart, selectionEnd]);
// Draw animation indicators
if (animatedTileIds && animatedTileIds.size > 0) {
for (const tileId of animatedTileIds) {
const x = (tileId % columns) * tileWidth;
const y = Math.floor(tileId / columns) * tileHeight;
// Draw small play icon in bottom-right corner
ctx.fillStyle = 'rgba(0, 180, 0, 0.9)';
ctx.beginPath();
ctx.moveTo(x + tileWidth - 12, y + tileHeight - 10);
ctx.lineTo(x + tileWidth - 12, y + tileHeight - 2);
ctx.lineTo(x + tileWidth - 4, y + tileHeight - 6);
ctx.closePath();
ctx.fill();
}
}
}, [image, columns, rows, tileWidth, tileHeight, selectedTiles, isSelecting, selectionStart, selectionEnd, animatedTileIds]);
useEffect(() => {
draw();
@@ -184,12 +209,47 @@ export const TilesetPreview: React.FC<TilesetPreviewProps> = ({
setSelectionEnd(null);
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
if (!onEditAnimation) return;
const coords = getTileCoords(e);
const tileId = coords.y * columns + coords.x;
setContextMenu({
x: e.clientX,
y: e.clientY,
tileId
});
};
const _handleCloseContextMenu = () => {
setContextMenu(null);
};
const handleEditAnimation = () => {
if (contextMenu && onEditAnimation) {
onEditAnimation(contextMenu.tileId);
}
setContextMenu(null);
};
// Close context menu when clicking outside
useEffect(() => {
const handleClick = () => setContextMenu(null);
if (contextMenu) {
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}
}, [contextMenu]);
return (
<div
ref={containerRef}
style={{
width: '100%',
height: '100%',
position: 'relative',
}}
onWheel={handleWheel}
>
@@ -204,7 +264,43 @@ export const TilesetPreview: React.FC<TilesetPreviewProps> = ({
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onContextMenu={handleContextMenu}
/>
{contextMenu && (
<div
className="tileset-context-menu"
style={{
position: 'fixed',
left: contextMenu.x,
top: contextMenu.y,
background: '#252526',
border: '1px solid #3c3c3c',
borderRadius: '4px',
padding: '4px 0',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
zIndex: 1000,
}}
>
<button
style={{
display: 'block',
width: '100%',
padding: '6px 16px',
background: 'none',
border: 'none',
color: '#e0e0e0',
fontSize: '12px',
textAlign: 'left',
cursor: 'pointer',
}}
onMouseEnter={(e) => e.currentTarget.style.background = '#094771'}
onMouseLeave={(e) => e.currentTarget.style.background = 'none'}
onClick={handleEditAnimation}
>
... ( #{contextMenu.tileId})
</button>
</div>
)}
</div>
);
};

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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 */}

View File

@@ -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)}

View File

@@ -3,7 +3,6 @@
* Tilemap Editor Module Entry
*/
import React from 'react';
import type { ServiceContainer, Entity } from '@esengine/ecs-framework';
import { Core } from '@esengine/ecs-framework';
import type {
@@ -24,7 +23,6 @@ import {
ComponentRegistry,
IDialogService,
IFileSystemService,
UIRegistry,
FileActionRegistry
} from '@esengine/editor-core';
import type { IDialog, IFileSystem } from '@esengine/editor-core';
@@ -32,7 +30,7 @@ import { TransformComponent } from '@esengine/engine-core';
// Runtime imports from @esengine/tilemap
import { TilemapComponent, TilemapCollider2DComponent, TilemapRuntimeModule } from '@esengine/tilemap';
import type { IPlugin, PluginDescriptor } from '@esengine/editor-core';
import type { IPlugin, ModuleManifest } from '@esengine/editor-core';
import { TilemapEditorPanel } from './components/panels/TilemapEditorPanel';
import { TilemapInspectorProvider } from './providers/TilemapInspectorProvider';
import { registerTilemapGizmo } from './gizmos/TilemapGizmo';
@@ -46,6 +44,7 @@ import './styles/TilemapEditor.css';
export { TilemapEditorPanel } from './components/panels/TilemapEditorPanel';
export { TilesetPanel } from './components/panels/TilesetPanel';
export { TilemapCanvas } from './components/TilemapCanvas';
export { TilemapViewport } from './components/TilemapViewport';
export { TilesetPreview } from './components/TilesetPreview';
export { useTilemapEditorStore } from './stores/TilemapEditorStore';
export type { TilemapEditorState, TilemapToolType, TileSelection } from './stores/TilemapEditorStore';
@@ -53,7 +52,10 @@ export type { ITilemapTool, ToolContext } from './tools/ITilemapTool';
export { BrushTool } from './tools/BrushTool';
export { EraserTool } from './tools/EraserTool';
export { FillTool } from './tools/FillTool';
export { RectangleTool } from './tools/RectangleTool';
export { SelectTool } from './tools/SelectTool';
export { TilemapInspectorProvider } from './providers/TilemapInspectorProvider';
export { TileAnimationEditor } from './components/panels/TileAnimationEditor';
/**
* Tilemap 编辑器模块
@@ -355,22 +357,26 @@ export class TilemapEditorModule implements IEditorModuleLoader {
export const tilemapEditorModule = new TilemapEditorModule();
/**
* Tilemap 插件描述符
* Tilemap Plugin Descriptor
* Tilemap 插件清单
* Tilemap Plugin Manifest
*/
const descriptor: PluginDescriptor = {
const manifest: ModuleManifest = {
id: '@esengine/tilemap',
name: 'Tilemap',
name: '@esengine/tilemap',
displayName: 'Tilemap',
version: '1.0.0',
description: 'Tilemap system with Tiled editor support',
category: 'tilemap',
enabledByDefault: false,
isEnginePlugin: true,
category: 'Rendering',
isCore: false,
defaultEnabled: false,
isEngineModule: true,
canContainContent: true,
modules: [
{ name: 'Runtime', type: 'runtime', loadingPhase: 'default' },
{ name: 'Editor', type: 'editor', loadingPhase: 'postDefault' }
]
dependencies: ['engine-core'],
exports: {
components: ['TilemapComponent', 'TilemapCollider2DComponent'],
systems: ['TilemapRenderingSystem'],
loaders: ['TilemapLoader', 'TilesetLoader']
}
};
/**
@@ -378,7 +384,7 @@ const descriptor: PluginDescriptor = {
* Complete Tilemap Plugin (runtime + editor)
*/
export const TilemapPlugin: IPlugin = {
descriptor,
manifest,
runtimeModule: new TilemapRuntimeModule(),
editorModule: tilemapEditorModule
};

View File

@@ -20,6 +20,8 @@ export interface LayerState {
visible: boolean;
locked: boolean;
opacity: number;
color: string;
hiddenInGame: boolean;
}
export interface TilemapEditorState {
@@ -86,6 +88,8 @@ export interface TilemapEditorState {
toggleLayerVisibility: (index: number) => void;
toggleLayerLocked: (index: number) => void;
setLayerOpacity: (index: number, opacity: number) => void;
setLayerColor: (index: number, color: string) => void;
setLayerHiddenInGame: (index: number, hidden: boolean) => void;
renameLayer: (index: number, name: string) => void;
}
@@ -215,6 +219,24 @@ export const useTilemapEditorStore = create<TilemapEditorState>((set, get) => ({
set({ layers: newLayers });
},
setLayerColor: (index, color) => {
const { layers } = get();
const layer = layers[index];
if (!layer) return;
const newLayers = [...layers];
newLayers[index] = { ...layer, color };
set({ layers: newLayers });
},
setLayerHiddenInGame: (index, hidden) => {
const { layers } = get();
const layer = layers[index];
if (!layer) return;
const newLayers = [...layers];
newLayers[index] = { ...layer, hiddenInGame: hidden };
set({ layers: newLayers });
},
renameLayer: (index, name) => {
const { layers } = get();
const layer = layers[index];

View File

@@ -0,0 +1,339 @@
/**
* Tile Animation Editor Styles
* 瓦片动画编辑器样式
*/
.tile-animation-editor-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.tile-animation-editor {
background: #252526;
border: 1px solid #3c3c3c;
border-radius: 8px;
width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.animation-editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #3c3c3c;
background: #2d2d2d;
border-radius: 8px 8px 0 0;
}
.animation-editor-header h3 {
margin: 0;
font-size: 14px;
font-weight: 500;
color: #e0e0e0;
}
.animation-editor-header .close-btn {
background: none;
border: none;
color: #808080;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.animation-editor-header .close-btn:hover {
background: #3c3c3c;
color: #e0e0e0;
}
.animation-editor-content {
display: flex;
padding: 16px;
gap: 16px;
flex: 1;
overflow: hidden;
}
/* Preview Section */
.animation-preview-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.preview-box {
background: #1a1a1a;
border: 1px solid #3c3c3c;
border-radius: 4px;
padding: 4px;
}
.animation-preview-canvas {
display: block;
image-rendering: pixelated;
}
.preview-controls {
display: flex;
align-items: center;
gap: 8px;
}
.preview-btn {
background: #3c3c3c;
border: none;
color: #e0e0e0;
cursor: pointer;
padding: 6px 10px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.preview-btn:hover {
background: #4c4c4c;
}
.preview-btn.active {
background: #0e639c;
}
.frame-indicator {
font-size: 12px;
color: #808080;
}
/* Frames Section */
.animation-frames-section {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.frames-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 12px;
color: #e0e0e0;
}
.frame-count {
color: #808080;
}
.frames-list {
flex: 1;
overflow-y: auto;
background: #1a1a1a;
border: 1px solid #3c3c3c;
border-radius: 4px;
padding: 8px;
min-height: 150px;
max-height: 200px;
}
.frames-empty {
text-align: center;
color: #808080;
font-size: 12px;
padding: 40px 20px;
}
.frame-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: #252526;
border: 1px solid #3c3c3c;
border-radius: 4px;
margin-bottom: 4px;
cursor: grab;
}
.frame-item:active {
cursor: grabbing;
}
.frame-item.drag-over {
border-color: #0e639c;
background: #1e3a5f;
}
.frame-drag-handle {
color: #606060;
cursor: grab;
}
.frame-preview {
flex-shrink: 0;
}
.frame-preview canvas {
display: block;
image-rendering: pixelated;
border: 1px solid #3c3c3c;
border-radius: 2px;
}
.frame-info {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.frame-tile-id {
color: #808080;
min-width: 32px;
}
.frame-duration-input {
width: 60px;
background: #1a1a1a;
border: 1px solid #3c3c3c;
color: #e0e0e0;
padding: 4px 6px;
border-radius: 3px;
font-size: 12px;
}
.frame-duration-input:focus {
outline: none;
border-color: #0e639c;
}
.duration-unit {
color: #808080;
}
.frame-delete-btn {
background: none;
border: none;
color: #808080;
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
}
.frame-delete-btn:hover {
background: #5a1d1d;
color: #ff6b6b;
}
/* Tileset Section */
.animation-tileset-section {
border-top: 1px solid #3c3c3c;
padding: 12px 16px;
}
.tileset-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 12px;
color: #e0e0e0;
}
.default-duration {
display: flex;
align-items: center;
gap: 6px;
color: #808080;
}
.default-duration input {
width: 60px;
background: #1a1a1a;
border: 1px solid #3c3c3c;
color: #e0e0e0;
padding: 4px 6px;
border-radius: 3px;
font-size: 12px;
}
.default-duration input:focus {
outline: none;
border-color: #0e639c;
}
.tileset-scroll-container {
max-height: 150px;
overflow: auto;
background: #1a1a1a;
border: 1px solid #3c3c3c;
border-radius: 4px;
}
.animation-tileset-canvas {
display: block;
image-rendering: pixelated;
cursor: pointer;
}
/* Footer */
.animation-editor-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-top: 1px solid #3c3c3c;
background: #2d2d2d;
border-radius: 0 0 8px 8px;
}
.footer-right {
display: flex;
gap: 8px;
}
.btn-primary,
.btn-secondary {
padding: 6px 16px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
border: none;
}
.btn-primary {
background: #0e639c;
color: #fff;
}
.btn-primary:hover {
background: #1177bb;
}
.btn-secondary {
background: #3c3c3c;
color: #e0e0e0;
}
.btn-secondary:hover {
background: #4c4c4c;
}

View File

@@ -9,6 +9,62 @@
user-select: none;
}
/* ==================== Mode Toggle ==================== */
.tileset-mode-toggle {
display: flex;
padding: 6px 8px;
gap: 2px;
background: #1e1e1e;
border-bottom: 1px solid #1a1a1a;
}
.mode-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
flex: 1;
height: 28px;
padding: 0 12px;
background: #2d2d30;
border: 1px solid #3c3c3c;
border-radius: 3px;
color: #888;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.mode-toggle-btn:first-child {
border-radius: 3px 0 0 3px;
border-right: none;
}
.mode-toggle-btn:last-child {
border-radius: 0 3px 3px 0;
}
.mode-toggle-btn:hover {
background: #3c3c3c;
color: #e0e0e0;
}
.mode-toggle-btn.active {
background: #094771;
border-color: #0078d4;
color: #fff;
}
.mode-toggle-btn.active:hover {
background: #0a5a8a;
}
.mode-toggle-btn svg {
width: 14px;
height: 14px;
}
/* ==================== Tool Buttons Row ==================== */
.tileset-tools {
display: flex;
@@ -242,6 +298,36 @@
height: 100%;
}
/* ==================== Collision Mode Hint ==================== */
.collision-mode-hint {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
text-align: center;
color: #888;
}
.collision-mode-hint svg {
color: #0078d4;
opacity: 0.8;
}
.collision-mode-title {
font-size: 13px;
font-weight: 600;
color: #e0e0e0;
margin-top: 8px;
}
.collision-mode-desc {
font-size: 11px;
color: #888;
line-height: 1.4;
}
.tileset-select-btn {
padding: 8px 16px;
background: transparent;

View File

@@ -349,6 +349,136 @@
width: 10px;
height: 10px;
color: #888;
flex-shrink: 0;
}
/* ==================== Material Field - AssetField Style ==================== */
.material-section-content {
padding: 8px 10px 8px 20px;
}
.material-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.material-field__label {
font-size: 11px;
color: #888;
}
.material-field__content {
display: flex;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
/* Thumbnail Preview */
.material-field__thumbnail {
width: 44px;
height: 44px;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 2px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: border-color 0.15s ease;
}
.material-field__thumbnail:hover {
border-color: #4a4a4a;
}
.material-field__thumbnail-icon {
color: #555;
}
/* Right side container */
.material-field__right {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
/* Dropdown selector */
.material-field__dropdown {
display: flex;
align-items: center;
height: 22px;
padding: 0 8px;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 2px;
cursor: pointer;
transition: border-color 0.15s ease;
min-width: 0;
}
.material-field__dropdown:hover {
border-color: #4a4a4a;
}
.material-field__value {
flex: 1;
font-size: 11px;
color: #888;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-style: italic;
}
.material-field__dropdown.has-value .material-field__value {
color: #ddd;
font-style: normal;
}
.material-field__dropdown-arrow {
color: #666;
flex-shrink: 0;
margin-left: 4px;
}
/* Action buttons row */
.material-field__actions {
display: flex;
align-items: center;
gap: 2px;
}
.material-field__btn {
width: 20px;
height: 20px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 2px;
color: #888;
cursor: pointer;
transition: all 0.15s ease;
}
.material-field__btn:hover {
background: #3a3a3a;
border-color: #4a4a4a;
color: #ccc;
}
.material-field__btn--clear:hover {
background: #4a2020;
border-color: #5a3030;
color: #f87171;
}
/* ==================== Layer List ==================== */
@@ -485,6 +615,29 @@
color: #c0c0c0;
}
.selected-layer-name.editable {
cursor: text;
padding: 2px 4px;
border-radius: 2px;
transition: background 0.15s ease;
}
.selected-layer-name.editable:hover {
background: rgba(255, 255, 255, 0.1);
}
.layer-name-input {
width: 100%;
height: 22px;
padding: 0 6px;
background: #1e1e1e;
border: 1px solid #0078d4;
border-radius: 2px;
color: #c0c0c0;
font-size: 11px;
outline: none;
}
/* ==================== Select Dropdown ==================== */
.property-row select {
appearance: none;
@@ -495,6 +648,57 @@
cursor: pointer;
}
/* ==================== Slider Property ==================== */
.slider-wrapper {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.property-slider {
flex: 1;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: #3c3c3c;
border-radius: 2px;
outline: none;
cursor: pointer;
}
.property-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: #0078d4;
border-radius: 50%;
cursor: pointer;
transition: background 0.15s ease;
}
.property-slider::-webkit-slider-thumb:hover {
background: #1a8cff;
}
.property-slider::-moz-range-thumb {
width: 12px;
height: 12px;
background: #0078d4;
border-radius: 50%;
border: none;
cursor: pointer;
}
.slider-value {
font-size: 10px;
color: #888;
min-width: 32px;
text-align: right;
font-family: 'Consolas', 'Monaco', monospace;
}
/* ==================== Scrollbar ==================== */
.details-content::-webkit-scrollbar,
.layer-list-container::-webkit-scrollbar {

View File

@@ -23,6 +23,57 @@ export class FillTool implements ITilemapTool {
// No action on up
}
getPreviewTiles(tileX: number, tileY: number, ctx: ToolContext): { x: number; y: number }[] {
const { tilemap, editingCollision, currentLayer } = ctx;
if (tileX < 0 || tileX >= tilemap.width || tileY < 0 || tileY >= tilemap.height) {
return [];
}
const tiles: { x: number; y: number }[] = [];
const maxPreviewTiles = 500;
if (editingCollision) {
const targetCollision = tilemap.hasCollision(tileX, tileY);
const stack: [number, number][] = [[tileX, tileY]];
const visited = new Set<string>();
while (stack.length > 0 && tiles.length < maxPreviewTiles) {
const [x, y] = stack.pop()!;
const key = `${x},${y}`;
if (visited.has(key)) continue;
if (x < 0 || x >= tilemap.width || y < 0 || y >= tilemap.height) continue;
if (tilemap.hasCollision(x, y) !== targetCollision) continue;
visited.add(key);
tiles.push({ x, y });
stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
}
} else {
const targetTile = tilemap.getTile(currentLayer, tileX, tileY);
const stack: [number, number][] = [[tileX, tileY]];
const visited = new Set<string>();
while (stack.length > 0 && tiles.length < maxPreviewTiles) {
const [x, y] = stack.pop()!;
const key = `${x},${y}`;
if (visited.has(key)) continue;
if (x < 0 || x >= tilemap.width || y < 0 || y >= tilemap.height) continue;
if (tilemap.getTile(currentLayer, x, y) !== targetTile) continue;
visited.add(key);
tiles.push({ x, y });
stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
}
}
return tiles;
}
private floodFill(startX: number, startY: number, ctx: ToolContext): void {
const { tilemap, selectedTiles, editingCollision, currentLayer } = ctx;

View File

@@ -0,0 +1,100 @@
/**
* Rectangle Tool - Draw rectangular areas of tiles
*/
import type { ITilemapTool, ToolContext } from './ITilemapTool';
export class RectangleTool implements ITilemapTool {
readonly id = 'rectangle';
readonly name = 'Rectangle';
readonly icon = 'Square';
readonly cursor = 'crosshair';
private _isDrawing = false;
private _startX = -1;
private _startY = -1;
private _currentX = -1;
private _currentY = -1;
onMouseDown(tileX: number, tileY: number, ctx: ToolContext): void {
if (ctx.layerLocked && !ctx.editingCollision) return;
this._isDrawing = true;
this._startX = tileX;
this._startY = tileY;
this._currentX = tileX;
this._currentY = tileY;
}
onMouseMove(tileX: number, tileY: number, _ctx: ToolContext): void {
if (!this._isDrawing) return;
this._currentX = tileX;
this._currentY = tileY;
}
onMouseUp(tileX: number, tileY: number, ctx: ToolContext): void {
if (!this._isDrawing) return;
if (ctx.layerLocked && !ctx.editingCollision) {
this.reset();
return;
}
this._currentX = tileX;
this._currentY = tileY;
this.fillRectangle(ctx);
this.reset();
}
getPreviewTiles(tileX: number, tileY: number, _ctx: ToolContext): { x: number; y: number }[] {
if (!this._isDrawing) {
return [{ x: tileX, y: tileY }];
}
const tiles: { x: number; y: number }[] = [];
const minX = Math.min(this._startX, tileX);
const maxX = Math.max(this._startX, tileX);
const minY = Math.min(this._startY, tileY);
const maxY = Math.max(this._startY, tileY);
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
tiles.push({ x, y });
}
}
return tiles;
}
private fillRectangle(ctx: ToolContext): void {
const { tilemap, selectedTiles, editingCollision, currentLayer } = ctx;
const minX = Math.min(this._startX, this._currentX);
const maxX = Math.max(this._startX, this._currentX);
const minY = Math.min(this._startY, this._currentY);
const maxY = Math.max(this._startY, this._currentY);
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
if (x < 0 || x >= tilemap.width || y < 0 || y >= tilemap.height) continue;
if (editingCollision) {
tilemap.setCollision(x, y, 1);
} else if (selectedTiles) {
const localX = (x - minX) % selectedTiles.width;
const localY = (y - minY) % selectedTiles.height;
const tileIndex = selectedTiles.tiles[localY * selectedTiles.width + localX] ?? 0;
tilemap.setTile(currentLayer, x, y, tileIndex);
} else {
tilemap.setTile(currentLayer, x, y, 1);
}
}
}
}
private reset(): void {
this._isDrawing = false;
this._startX = -1;
this._startY = -1;
this._currentX = -1;
this._currentY = -1;
}
}

View File

@@ -0,0 +1,94 @@
/**
* Select Tool - Select rectangular regions of tiles for copy/move operations
*/
import type { ITilemapTool, ToolContext } from './ITilemapTool';
import { useTilemapEditorStore } from '../stores/TilemapEditorStore';
export class SelectTool implements ITilemapTool {
readonly id = 'select';
readonly name = 'Select';
readonly icon = 'BoxSelect';
readonly cursor = 'crosshair';
private _isSelecting = false;
private _startX = -1;
private _startY = -1;
private _currentX = -1;
private _currentY = -1;
onMouseDown(tileX: number, tileY: number, _ctx: ToolContext): void {
this._isSelecting = true;
this._startX = tileX;
this._startY = tileY;
this._currentX = tileX;
this._currentY = tileY;
}
onMouseMove(tileX: number, tileY: number, _ctx: ToolContext): void {
if (!this._isSelecting) return;
this._currentX = tileX;
this._currentY = tileY;
}
onMouseUp(tileX: number, tileY: number, ctx: ToolContext): void {
if (!this._isSelecting) return;
this._currentX = tileX;
this._currentY = tileY;
const minX = Math.max(0, Math.min(this._startX, this._currentX));
const maxX = Math.min(ctx.tilemap.width - 1, Math.max(this._startX, this._currentX));
const minY = Math.max(0, Math.min(this._startY, this._currentY));
const maxY = Math.min(ctx.tilemap.height - 1, Math.max(this._startY, this._currentY));
const width = maxX - minX + 1;
const height = maxY - minY + 1;
const tiles: number[] = [];
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
const tileIndex = ctx.tilemap.getTile(ctx.currentLayer, x, y);
tiles.push(tileIndex);
}
}
useTilemapEditorStore.getState().setSelectedTiles({
x: minX,
y: minY,
width,
height,
tiles
});
this.reset();
}
getPreviewTiles(tileX: number, tileY: number, _ctx: ToolContext): { x: number; y: number }[] {
if (!this._isSelecting) {
return [{ x: tileX, y: tileY }];
}
const tiles: { x: number; y: number }[] = [];
const minX = Math.min(this._startX, tileX);
const maxX = Math.max(this._startX, tileX);
const minY = Math.min(this._startY, tileY);
const maxY = Math.max(this._startY, tileY);
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
tiles.push({ x, y });
}
}
return tiles;
}
private reset(): void {
this._isSelecting = false;
this._startX = -1;
this._startY = -1;
this._currentX = -1;
this._currentY = -1;
}
}