Feature/editor optimization (#251)
* refactor: 编辑器/运行时架构拆分与构建系统升级 * feat(core): 层级系统重构与UI变换矩阵修复 * refactor: 移除 ecs-components 聚合包并修复跨包组件查找问题 * fix(physics): 修复跨包组件类引用问题 * feat: 统一运行时架构与浏览器运行支持 * feat(asset): 实现浏览器运行时资产加载系统 * fix: 修复文档、CodeQL安全问题和CI类型检查错误 * fix: 修复文档、CodeQL安全问题和CI类型检查错误 * fix: 修复文档、CodeQL安全问题、CI类型检查和测试错误 * test: 补齐核心模块测试用例,修复CI构建配置 * fix: 修复测试用例中的类型错误和断言问题 * fix: 修复 turbo build:npm 任务的依赖顺序问题 * fix: 修复 CI 构建错误并优化构建性能
This commit is contained in:
435
packages/tilemap-editor/src/components/TilemapCanvas.tsx
Normal file
435
packages/tilemap-editor/src/components/TilemapCanvas.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Tilemap Canvas - Main editing canvas
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
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 TilemapCanvasProps {
|
||||
tilemap: TilemapComponent;
|
||||
tilesetImage: HTMLImageElement | null;
|
||||
onTilemapChange?: () => void;
|
||||
}
|
||||
|
||||
const tools: Record<string, ITilemapTool> = {
|
||||
brush: new BrushTool(),
|
||||
eraser: new EraserTool(),
|
||||
fill: new FillTool(),
|
||||
};
|
||||
|
||||
export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
||||
tilemap,
|
||||
tilesetImage,
|
||||
onTilemapChange,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
currentTool,
|
||||
zoom,
|
||||
panX,
|
||||
panY,
|
||||
showGrid,
|
||||
showCollision,
|
||||
selectedTiles,
|
||||
brushSize,
|
||||
currentLayer,
|
||||
editingCollision,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
tilesetColumns,
|
||||
layers,
|
||||
setPan,
|
||||
setZoom,
|
||||
pushUndo,
|
||||
} = useTilemapEditorStore();
|
||||
|
||||
// Get layer locked state
|
||||
const layerLocked = layers[currentLayer]?.locked ?? false;
|
||||
|
||||
// Create a dependency key from layers state to trigger redraw when visibility/opacity changes
|
||||
const layersKey = layers.map(l => `${l.visible}-${l.opacity}`).join(',');
|
||||
|
||||
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
|
||||
const canvasWidth = tilemap.width * tileWidth;
|
||||
const canvasHeight = tilemap.height * tileHeight;
|
||||
|
||||
// Draw the tilemap
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Clear
|
||||
ctx.fillStyle = '#2d2d2d';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(panX, panY);
|
||||
ctx.scale(zoom, zoom);
|
||||
|
||||
// Draw tilemap background
|
||||
ctx.fillStyle = '#1a1a1a';
|
||||
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
|
||||
// Draw tiles from all visible layers (from bottom to top)
|
||||
if (tilesetImage) {
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Draw all layers from tilemap component, respecting visibility and opacity
|
||||
const tilemapLayers = tilemap.layers;
|
||||
|
||||
for (let layerIndex = tilemapLayers.length - 1; layerIndex >= 0; layerIndex--) {
|
||||
const tilemapLayer = tilemapLayers[layerIndex];
|
||||
if (!tilemapLayer || !tilemapLayer.visible) continue; // Skip undefined or invisible layers
|
||||
|
||||
// Apply layer opacity
|
||||
const savedAlpha = ctx.globalAlpha;
|
||||
ctx.globalAlpha = tilemapLayer.opacity ?? 1;
|
||||
|
||||
for (let y = 0; y < tilemap.height; y++) {
|
||||
for (let x = 0; x < tilemap.width; x++) {
|
||||
const tileIndex = tilemap.getTile(layerIndex, x, y);
|
||||
if (tileIndex > 0) {
|
||||
// Calculate source position in tileset
|
||||
const srcX = ((tileIndex - 1) % tilesetColumns) * tileWidth;
|
||||
const srcY = Math.floor((tileIndex - 1) / tilesetColumns) * tileHeight;
|
||||
|
||||
// Only draw if tile is within tileset bounds
|
||||
if (srcX + tileWidth <= tilesetImage.width && srcY + tileHeight <= tilesetImage.height) {
|
||||
ctx.drawImage(
|
||||
tilesetImage,
|
||||
srcX, srcY, tileWidth, tileHeight,
|
||||
x * tileWidth, y * tileHeight, tileWidth, tileHeight
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore opacity
|
||||
ctx.globalAlpha = savedAlpha;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw collision overlay
|
||||
if (showCollision) {
|
||||
ctx.fillStyle = 'rgba(255, 0, 0, 0.3)';
|
||||
for (let y = 0; y < tilemap.height; y++) {
|
||||
for (let x = 0; x < tilemap.width; x++) {
|
||||
if (tilemap.hasCollision(x, y)) {
|
||||
ctx.fillRect(x * tileWidth, y * tileHeight, tileWidth, tileHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw grid
|
||||
if (showGrid) {
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||
ctx.lineWidth = 1 / zoom;
|
||||
|
||||
for (let x = 0; x <= tilemap.width; x++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x * tileWidth, 0);
|
||||
ctx.lineTo(x * tileWidth, canvasHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
for (let y = 0; y <= tilemap.height; y++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y * tileHeight);
|
||||
ctx.lineTo(canvasWidth, y * tileHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Draw tool preview
|
||||
if (mousePos && tools[currentTool]?.getPreviewTiles) {
|
||||
const tool = tools[currentTool];
|
||||
const toolContext: ToolContext = {
|
||||
tilemap,
|
||||
selectedTiles,
|
||||
currentLayer,
|
||||
layerLocked,
|
||||
brushSize,
|
||||
editingCollision,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
};
|
||||
|
||||
const previewTiles = tool.getPreviewTiles!(mousePos.tileX, mousePos.tileY, toolContext);
|
||||
|
||||
ctx.fillStyle = editingCollision ? 'rgba(255, 0, 0, 0.3)' : 'rgba(0, 120, 212, 0.3)';
|
||||
for (const tile of previewTiles) {
|
||||
if (tile.x >= 0 && tile.x < tilemap.width && tile.y >= 0 && tile.y < tilemap.height) {
|
||||
ctx.fillRect(tile.x * tileWidth, tile.y * tileHeight, tileWidth, tileHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}, [tilemap, tilesetImage, zoom, panX, panY, showGrid, showCollision, mousePos, currentTool, selectedTiles, brushSize, currentLayer, layerLocked, editingCollision, tileWidth, tileHeight, tilesetColumns, canvasWidth, canvasHeight, layersKey]);
|
||||
|
||||
// Update canvas size
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
let rafId: number | null = null;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
// 使用 requestAnimationFrame 避免 ResizeObserver loop 错误
|
||||
// Use requestAnimationFrame to avoid ResizeObserver loop errors
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const newWidth = container.clientWidth;
|
||||
const newHeight = container.clientHeight;
|
||||
if (canvas.width !== newWidth || canvas.height !== newHeight) {
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
draw();
|
||||
}
|
||||
rafId = null;
|
||||
});
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [draw]);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
}, [draw]);
|
||||
|
||||
// Center view on first mount
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Only center if pan is at default position (0, 0)
|
||||
if (panX === 0 && panY === 0) {
|
||||
const containerWidth = container.clientWidth;
|
||||
const containerHeight = container.clientHeight;
|
||||
const mapPixelWidth = canvasWidth * zoom;
|
||||
const mapPixelHeight = canvasHeight * zoom;
|
||||
|
||||
const centerX = (containerWidth - mapPixelWidth) / 2;
|
||||
const centerY = (containerHeight - mapPixelHeight) / 2;
|
||||
|
||||
setPan(centerX, centerY);
|
||||
}
|
||||
}, []); // Only run on mount
|
||||
|
||||
// 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?.();
|
||||
draw();
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
setPan(panX + dx, 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?.();
|
||||
}
|
||||
}
|
||||
|
||||
draw();
|
||||
};
|
||||
|
||||
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);
|
||||
draw();
|
||||
};
|
||||
|
||||
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}
|
||||
className="tilemap-canvas"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
style={{ cursor: getCursor() }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
210
packages/tilemap-editor/src/components/TilesetPreview.tsx
Normal file
210
packages/tilemap-editor/src/components/TilesetPreview.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Tileset Preview Component - Display and select tiles from a tileset
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { useTilemapEditorStore, type TileSelection } from '../stores/TilemapEditorStore';
|
||||
|
||||
interface TilesetPreviewProps {
|
||||
imageUrl: string;
|
||||
tileWidth: number;
|
||||
tileHeight: number;
|
||||
columns: number;
|
||||
rows: number;
|
||||
onSelectionChange?: (selection: TileSelection) => void;
|
||||
}
|
||||
|
||||
export const TilesetPreview: React.FC<TilesetPreviewProps> = ({
|
||||
imageUrl,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
columns,
|
||||
rows,
|
||||
onSelectionChange,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
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 selectedTiles = useTilemapEditorStore(state => state.selectedTiles);
|
||||
const setSelectedTiles = useTilemapEditorStore(state => state.setSelectedTiles);
|
||||
|
||||
// Load image
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.onload = () => setImage(img);
|
||||
img.src = imageUrl;
|
||||
}, [imageUrl]);
|
||||
|
||||
// Draw tileset
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !image) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size based on actual image size (+1 for border lines)
|
||||
canvas.width = image.width + 1;
|
||||
canvas.height = image.height + 1;
|
||||
|
||||
// Draw image
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
// Draw grid only within the actual tileset area
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let x = 0; x <= columns; x++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x * tileWidth + 0.5, 0);
|
||||
ctx.lineTo(x * tileWidth + 0.5, image.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
for (let y = 0; y <= rows; y++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y * tileHeight + 0.5);
|
||||
ctx.lineTo(image.width, y * tileHeight + 0.5);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw selection preview during drag
|
||||
if (isSelecting && selectionStart && selectionEnd) {
|
||||
const minX = Math.min(selectionStart.x, selectionEnd.x);
|
||||
const maxX = Math.max(selectionStart.x, selectionEnd.x);
|
||||
const minY = Math.min(selectionStart.y, selectionEnd.y);
|
||||
const maxY = Math.max(selectionStart.y, selectionEnd.y);
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 120, 212, 0.3)';
|
||||
ctx.fillRect(
|
||||
minX * tileWidth,
|
||||
minY * tileHeight,
|
||||
(maxX - minX + 1) * tileWidth,
|
||||
(maxY - minY + 1) * tileHeight
|
||||
);
|
||||
}
|
||||
|
||||
// Draw current selection
|
||||
if (selectedTiles && !isSelecting) {
|
||||
ctx.strokeStyle = '#0078d4';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(
|
||||
selectedTiles.x * tileWidth + 1,
|
||||
selectedTiles.y * tileHeight + 1,
|
||||
selectedTiles.width * tileWidth - 2,
|
||||
selectedTiles.height * tileHeight - 2
|
||||
);
|
||||
}
|
||||
}, [image, columns, rows, tileWidth, tileHeight, selectedTiles, isSelecting, selectionStart, selectionEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
}, [draw]);
|
||||
|
||||
const getTileCoords = (e: React.MouseEvent): { x: number; y: number } => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const x = Math.floor((e.clientX - rect.left) * scaleX / tileWidth);
|
||||
const y = Math.floor((e.clientY - rect.top) * scaleY / tileHeight);
|
||||
|
||||
return {
|
||||
x: Math.max(0, Math.min(columns - 1, x)),
|
||||
y: Math.max(0, Math.min(rows - 1, y)),
|
||||
};
|
||||
};
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
// Only zoom when Ctrl is pressed
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
setZoom(z => Math.max(0.5, Math.min(5, z * delta)));
|
||||
}
|
||||
// Otherwise let the default scroll behavior work
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
const coords = getTileCoords(e);
|
||||
setIsSelecting(true);
|
||||
setSelectionStart(coords);
|
||||
setSelectionEnd(coords);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isSelecting) return;
|
||||
const coords = getTileCoords(e);
|
||||
setSelectionEnd(coords);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!isSelecting || !selectionStart || !selectionEnd) {
|
||||
setIsSelecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const minX = Math.min(selectionStart.x, selectionEnd.x);
|
||||
const maxX = Math.max(selectionStart.x, selectionEnd.x);
|
||||
const minY = Math.min(selectionStart.y, selectionEnd.y);
|
||||
const maxY = Math.max(selectionStart.y, selectionEnd.y);
|
||||
|
||||
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++) {
|
||||
// Tile index = y * columns + x + 1 (0 is empty)
|
||||
tiles.push(y * columns + x + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const selection: TileSelection = {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width,
|
||||
height,
|
||||
tiles,
|
||||
};
|
||||
|
||||
setSelectedTiles(selection);
|
||||
onSelectionChange?.(selection);
|
||||
|
||||
setIsSelecting(false);
|
||||
setSelectionStart(null);
|
||||
setSelectionEnd(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="tileset-canvas"
|
||||
style={{
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
277
packages/tilemap-editor/src/components/panels/LayerPanel.tsx
Normal file
277
packages/tilemap-editor/src/components/panels/LayerPanel.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Layer Panel Component
|
||||
* 图层面板组件
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Eye, EyeOff, Lock, Unlock, Plus, Trash2, ChevronUp, ChevronDown, Paintbrush, Shield, Grid3X3 } from 'lucide-react';
|
||||
import { useTilemapEditorStore, type LayerState } from '../../stores/TilemapEditorStore';
|
||||
import type { TilemapComponent } from '@esengine/tilemap';
|
||||
|
||||
interface LayerPanelProps {
|
||||
tilemap: TilemapComponent | null;
|
||||
onAddLayer?: () => void;
|
||||
onRemoveLayer?: (index: number) => void;
|
||||
onMoveLayer?: (fromIndex: number, toIndex: number) => void;
|
||||
}
|
||||
|
||||
export const LayerPanel: React.FC<LayerPanelProps> = ({
|
||||
tilemap,
|
||||
onAddLayer,
|
||||
onRemoveLayer,
|
||||
onMoveLayer,
|
||||
}) => {
|
||||
const {
|
||||
currentLayer,
|
||||
layers,
|
||||
setCurrentLayer,
|
||||
toggleLayerVisibility,
|
||||
toggleLayerLocked,
|
||||
setLayerOpacity,
|
||||
renameLayer,
|
||||
showCollision,
|
||||
setShowCollision,
|
||||
editingCollision,
|
||||
setEditingCollision,
|
||||
} = useTilemapEditorStore();
|
||||
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
|
||||
const handleDoubleClick = useCallback((index: number, name: string) => {
|
||||
setEditingIndex(index);
|
||||
setEditName(name);
|
||||
}, []);
|
||||
|
||||
const handleNameSubmit = useCallback((index: number) => {
|
||||
if (editName.trim()) {
|
||||
renameLayer(index, editName.trim());
|
||||
// Also update the tilemap component
|
||||
if (tilemap && tilemap.layers[index]) {
|
||||
tilemap.layers[index].name = editName.trim();
|
||||
}
|
||||
}
|
||||
setEditingIndex(null);
|
||||
}, [editName, renameLayer, tilemap]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent, index: number) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleNameSubmit(index);
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingIndex(null);
|
||||
}
|
||||
}, [handleNameSubmit]);
|
||||
|
||||
const handleVisibilityToggle = useCallback((index: number) => {
|
||||
toggleLayerVisibility(index);
|
||||
// Also update the tilemap component
|
||||
if (tilemap && tilemap.layers[index]) {
|
||||
tilemap.layers[index].visible = !tilemap.layers[index].visible;
|
||||
tilemap.renderDirty = true;
|
||||
}
|
||||
}, [toggleLayerVisibility, tilemap]);
|
||||
|
||||
const handleOpacityChange = useCallback((index: number, opacity: number) => {
|
||||
setLayerOpacity(index, opacity);
|
||||
// Also update the tilemap component
|
||||
if (tilemap && tilemap.layers[index]) {
|
||||
tilemap.layers[index].opacity = opacity;
|
||||
tilemap.renderDirty = true;
|
||||
}
|
||||
}, [setLayerOpacity, tilemap]);
|
||||
|
||||
if (!tilemap || layers.length === 0) {
|
||||
return (
|
||||
<div className="layer-panel">
|
||||
<div className="layer-panel-header">
|
||||
<span>图层</span>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={onAddLayer}
|
||||
title="添加图层"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="layer-panel-empty">
|
||||
暂无图层
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="layer-panel">
|
||||
<div className="layer-panel-header">
|
||||
<span>图层 ({layers.length})</span>
|
||||
<div className="layer-panel-actions">
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={onAddLayer}
|
||||
title="添加图层"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="layer-list">
|
||||
{/* Collision Layer - Special layer */}
|
||||
<div
|
||||
className={`layer-item collision-layer ${editingCollision ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
setEditingCollision(true);
|
||||
// Auto-show collision when editing
|
||||
if (!showCollision) {
|
||||
setShowCollision(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="layer-controls">
|
||||
<button
|
||||
className="icon-button small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowCollision(!showCollision);
|
||||
}}
|
||||
title={showCollision ? '隐藏碰撞层' : '显示碰撞层'}
|
||||
>
|
||||
{showCollision ? <Eye size={12} /> : <EyeOff size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="layer-info">
|
||||
{editingCollision && (
|
||||
<span className="layer-active-indicator" title="当前编辑碰撞">
|
||||
<Shield size={14} />
|
||||
</span>
|
||||
)}
|
||||
<span className="layer-name collision-name">
|
||||
<Shield size={12} style={{ marginRight: 4, opacity: 0.7 }} />
|
||||
碰撞层
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="layer-separator" />
|
||||
|
||||
{/* Tile Layers */}
|
||||
{layers.map((layer, index) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`layer-item ${index === currentLayer && !editingCollision ? 'selected' : ''} ${layer.locked ? 'locked' : ''}`}
|
||||
onClick={() => {
|
||||
setEditingCollision(false);
|
||||
setCurrentLayer(index);
|
||||
}}
|
||||
>
|
||||
<div className="layer-controls">
|
||||
<button
|
||||
className="icon-button small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleVisibilityToggle(index);
|
||||
}}
|
||||
title={layer.visible ? '隐藏图层' : '显示图层'}
|
||||
>
|
||||
{layer.visible ? <Eye size={12} /> : <EyeOff size={12} />}
|
||||
</button>
|
||||
<button
|
||||
className="icon-button small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleLayerLocked(index);
|
||||
}}
|
||||
title={layer.locked ? '解锁图层' : '锁定图层'}
|
||||
>
|
||||
{layer.locked ? <Lock size={12} /> : <Unlock size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="layer-info">
|
||||
{index === currentLayer && (
|
||||
<span className="layer-active-indicator" title="当前绘制图层">
|
||||
<Paintbrush size={14} />
|
||||
</span>
|
||||
)}
|
||||
{editingIndex === index ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onBlur={() => handleNameSubmit(index)}
|
||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||
autoFocus
|
||||
className="layer-name-input"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="layer-name"
|
||||
onDoubleClick={() => handleDoubleClick(index, layer.name)}
|
||||
>
|
||||
{layer.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="layer-actions">
|
||||
<button
|
||||
className="icon-button small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveLayer?.(index, index - 1);
|
||||
}}
|
||||
disabled={index === 0}
|
||||
title="上移图层"
|
||||
>
|
||||
<ChevronUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveLayer?.(index, index + 1);
|
||||
}}
|
||||
disabled={index === layers.length - 1}
|
||||
title="下移图层"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button small danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveLayer?.(index);
|
||||
}}
|
||||
disabled={layers.length <= 1}
|
||||
title="删除图层"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Opacity slider for selected layer */}
|
||||
{layers[currentLayer] && (
|
||||
<div className="layer-opacity-control">
|
||||
<label>Opacity</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={layers[currentLayer].opacity}
|
||||
onChange={(e) => handleOpacityChange(currentLayer, parseFloat(e.target.value))}
|
||||
title={`Opacity for ${layers[currentLayer].name}`}
|
||||
/>
|
||||
<span>{Math.round(layers[currentLayer].opacity * 100)}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayerPanel;
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Tile Set Selector Panel - Left panel for selecting tiles
|
||||
* 瓦片集选择面板 - 左侧面板用于选择瓦片
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Paintbrush, Eraser, PaintBucket, ChevronDown, Grid3x3, Search } from 'lucide-react';
|
||||
import { useTilemapEditorStore, type TilemapToolType } from '../../stores/TilemapEditorStore';
|
||||
import { TilesetPreview } from '../TilesetPreview';
|
||||
import '../../styles/TileSetSelectorPanel.css';
|
||||
|
||||
interface TilesetOption {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface TileSetSelectorPanelProps {
|
||||
tilesets: TilesetOption[];
|
||||
activeTilesetIndex: number;
|
||||
onTilesetChange: (index: number) => void;
|
||||
onAddTileset: () => void;
|
||||
}
|
||||
|
||||
export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
||||
tilesets,
|
||||
activeTilesetIndex,
|
||||
onTilesetChange,
|
||||
onAddTileset
|
||||
}) => {
|
||||
const {
|
||||
currentTool,
|
||||
setCurrentTool,
|
||||
tilesetImageUrl,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
tilesetColumns,
|
||||
tilesetRows,
|
||||
selectedTiles
|
||||
} = useTilemapEditorStore();
|
||||
|
||||
const [showTilesetDropdown, setShowTilesetDropdown] = useState(false);
|
||||
const [previewZoom, setPreviewZoom] = useState(1);
|
||||
|
||||
const handleToolChange = useCallback((tool: TilemapToolType) => {
|
||||
setCurrentTool(tool);
|
||||
}, [setCurrentTool]);
|
||||
|
||||
const activeTileset = tilesets[activeTilesetIndex];
|
||||
|
||||
return (
|
||||
<div className="tileset-selector-panel">
|
||||
{/* Tool buttons */}
|
||||
<div className="tileset-tools">
|
||||
<button
|
||||
className={`tileset-tool-btn ${currentTool === 'brush' ? 'active' : ''}`}
|
||||
onClick={() => handleToolChange('brush')}
|
||||
title="绘制"
|
||||
>
|
||||
<Paintbrush size={24} />
|
||||
<span>绘制</span>
|
||||
</button>
|
||||
<button
|
||||
className={`tileset-tool-btn ${currentTool === 'eraser' ? 'active' : ''}`}
|
||||
onClick={() => handleToolChange('eraser')}
|
||||
title="橡皮擦"
|
||||
>
|
||||
<Eraser size={24} />
|
||||
<span>橡皮擦</span>
|
||||
</button>
|
||||
<button
|
||||
className={`tileset-tool-btn ${currentTool === 'fill' ? 'active' : ''}`}
|
||||
onClick={() => handleToolChange('fill')}
|
||||
title="填充"
|
||||
>
|
||||
<PaintBucket size={24} />
|
||||
<span>填充</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active Tile Set selector */}
|
||||
<div className="tileset-selector">
|
||||
<div className="tileset-selector-row">
|
||||
<label>活跃瓦片集</label>
|
||||
<div className="tileset-selector-actions">
|
||||
<button className="tileset-action-btn" title="显示网格">
|
||||
<Grid3x3 size={14} />
|
||||
</button>
|
||||
<button className="tileset-action-btn" title="搜索">
|
||||
<Search size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tileset-dropdown-wrapper">
|
||||
<button
|
||||
className="tileset-dropdown-btn"
|
||||
onClick={() => setShowTilesetDropdown(!showTilesetDropdown)}
|
||||
>
|
||||
<span>{activeTileset?.name || '(无)'}</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{showTilesetDropdown && (
|
||||
<div className="tileset-dropdown-menu">
|
||||
<button
|
||||
className={`tileset-dropdown-item ${activeTilesetIndex === -1 ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
onTilesetChange(-1);
|
||||
setShowTilesetDropdown(false);
|
||||
}}
|
||||
>
|
||||
(无)
|
||||
</button>
|
||||
{tilesets.map((tileset, index) => (
|
||||
<button
|
||||
key={tileset.path}
|
||||
className={`tileset-dropdown-item ${index === activeTilesetIndex ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
onTilesetChange(index);
|
||||
setShowTilesetDropdown(false);
|
||||
}}
|
||||
>
|
||||
{tileset.name}
|
||||
</button>
|
||||
))}
|
||||
<div className="tileset-dropdown-divider" />
|
||||
<button
|
||||
className="tileset-dropdown-item add-new"
|
||||
onClick={() => {
|
||||
onAddTileset();
|
||||
setShowTilesetDropdown(false);
|
||||
}}
|
||||
>
|
||||
+ 添加瓦片集...
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zoom and title header */}
|
||||
<div className="tileset-header">
|
||||
<span className="tileset-zoom-label">缩放{previewZoom}:1</span>
|
||||
<span className="tileset-title">瓦片集选择器</span>
|
||||
</div>
|
||||
|
||||
{/* Tile preview area */}
|
||||
<div className="tileset-preview-area">
|
||||
{tilesetImageUrl ? (
|
||||
<TilesetPreview
|
||||
imageUrl={tilesetImageUrl}
|
||||
tileWidth={tileWidth}
|
||||
tileHeight={tileHeight}
|
||||
columns={tilesetColumns}
|
||||
rows={tilesetRows}
|
||||
/>
|
||||
) : (
|
||||
<div className="tileset-empty-hint">
|
||||
<button className="tileset-select-btn" onClick={onAddTileset}>
|
||||
选择瓦片集
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection info */}
|
||||
{selectedTiles && (
|
||||
<div className="tileset-selection-info">
|
||||
已选择: {selectedTiles.width}×{selectedTiles.height}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TileSetSelectorPanel;
|
||||
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* Tilemap Details Panel - Right panel with grouped properties
|
||||
* Tilemap 详情面板 - 右侧分组属性面板
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Copy,
|
||||
X,
|
||||
Search,
|
||||
Settings,
|
||||
Eye,
|
||||
EyeOff
|
||||
} from 'lucide-react';
|
||||
import { useTilemapEditorStore, type LayerState } from '../../stores/TilemapEditorStore';
|
||||
import type { TilemapComponent } from '@esengine/tilemap';
|
||||
import '../../styles/TilemapDetailsPanel.css';
|
||||
|
||||
interface TilemapDetailsPanelProps {
|
||||
tilemap: TilemapComponent | null;
|
||||
onAddLayer: () => void;
|
||||
onRemoveLayer: (index: number) => void;
|
||||
onMoveLayer: (from: number, to: number) => void;
|
||||
onTilemapChange: () => void;
|
||||
onOpenAssetPicker: () => void;
|
||||
}
|
||||
|
||||
// Collapsible section component
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Section: React.FC<SectionProps> = ({ title, defaultOpen = true, children }) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="details-section">
|
||||
<div
|
||||
className="details-section-header"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="details-section-content">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Property row component
|
||||
interface PropertyRowProps {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
indent?: boolean;
|
||||
}
|
||||
|
||||
const PropertyRow: React.FC<PropertyRowProps> = ({ label, children, indent }) => (
|
||||
<div className={`property-row ${indent ? 'indented' : ''}`}>
|
||||
<label>{label}</label>
|
||||
<div className="property-value">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Toggle property - unified style matching PropertyInspector
|
||||
interface TogglePropertyProps {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
indent?: boolean;
|
||||
}
|
||||
|
||||
const ToggleProperty: React.FC<TogglePropertyProps> = ({ label, checked, onChange, indent }) => (
|
||||
<div className={`property-row toggle-row ${indent ? 'indented' : ''}`}>
|
||||
<label>{label}</label>
|
||||
<button
|
||||
className={`property-toggle ${checked ? 'property-toggle-on' : 'property-toggle-off'}`}
|
||||
onClick={() => onChange(!checked)}
|
||||
type="button"
|
||||
>
|
||||
<span className="property-toggle-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Number input property
|
||||
interface NumberPropertyProps {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
const NumberProperty: React.FC<NumberPropertyProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
step = 1
|
||||
}) => (
|
||||
<PropertyRow label={label}>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
// Color property - unified style matching PropertyInspector
|
||||
interface ColorPropertyProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
showReset?: boolean;
|
||||
}
|
||||
|
||||
const ColorProperty: React.FC<ColorPropertyProps> = ({ label, value, onChange }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handlePreviewClick = () => {
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<PropertyRow label={label}>
|
||||
<div className="property-color-wrapper">
|
||||
<div
|
||||
className="property-color-preview"
|
||||
style={{ backgroundColor: value }}
|
||||
onClick={handlePreviewClick}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="color"
|
||||
className="property-input-color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="property-input property-input-color-text"
|
||||
value={value.toUpperCase()}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (/^#[0-9A-Fa-f]{0,6}$/.test(val)) {
|
||||
onChange(val);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value;
|
||||
if (!/^#[0-9A-Fa-f]{6}$/.test(val)) {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PropertyRow>
|
||||
);
|
||||
};
|
||||
|
||||
export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
||||
tilemap,
|
||||
onAddLayer,
|
||||
onRemoveLayer,
|
||||
onMoveLayer,
|
||||
onTilemapChange,
|
||||
onOpenAssetPicker
|
||||
}) => {
|
||||
const {
|
||||
layers,
|
||||
currentLayer,
|
||||
setCurrentLayer,
|
||||
toggleLayerVisibility,
|
||||
setLayerOpacity,
|
||||
showCollision,
|
||||
setShowCollision
|
||||
} = useTilemapEditorStore();
|
||||
|
||||
// Layer properties state - synced with store's visibility
|
||||
const selectedLayer = layers[currentLayer];
|
||||
const [hiddenInGame, setHiddenInGame] = useState(false);
|
||||
const [layerCollides, setLayerCollides] = useState(true);
|
||||
const [overrideCollisionThickness, setOverrideCollisionThickness] = useState(false);
|
||||
const [overrideCollisionOffset, setOverrideCollisionOffset] = useState(false);
|
||||
const [collisionThickness, setCollisionThickness] = useState(50.0);
|
||||
const [collisionOffset, setCollisionOffset] = useState(0.0);
|
||||
const [layerColor, setLayerColor] = useState('#ffffff');
|
||||
|
||||
// hiddenInEditor is derived from layer visibility (inverse relationship)
|
||||
const hiddenInEditor = selectedLayer ? !selectedLayer.visible : false;
|
||||
|
||||
const handleHiddenInEditorChange = useCallback((hidden: boolean) => {
|
||||
if (currentLayer >= 0 && currentLayer < layers.length) {
|
||||
toggleLayerVisibility(currentLayer);
|
||||
// Also update tilemap component
|
||||
if (tilemap && tilemap.layers[currentLayer]) {
|
||||
tilemap.layers[currentLayer].visible = !hidden;
|
||||
tilemap.renderDirty = true;
|
||||
onTilemapChange();
|
||||
}
|
||||
}
|
||||
}, [currentLayer, layers.length, toggleLayerVisibility, tilemap, onTilemapChange]);
|
||||
|
||||
// Handle eye icon click in layer list
|
||||
const handleLayerVisibilityToggle = useCallback((index: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toggleLayerVisibility(index);
|
||||
// Also update tilemap component
|
||||
if (tilemap && tilemap.layers[index]) {
|
||||
tilemap.layers[index].visible = !tilemap.layers[index].visible;
|
||||
tilemap.renderDirty = true;
|
||||
onTilemapChange();
|
||||
}
|
||||
}, [toggleLayerVisibility, tilemap, onTilemapChange]);
|
||||
|
||||
// Colors
|
||||
const [tileGridColor, setTileGridColor] = useState('#333333');
|
||||
const [multiTileGridColor, setMultiTileGridColor] = useState('#ff0000');
|
||||
const [layerGridColor, setLayerGridColor] = useState('#00ff00');
|
||||
|
||||
const handleLayerSelect = useCallback((index: number) => {
|
||||
setCurrentLayer(index);
|
||||
}, [setCurrentLayer]);
|
||||
|
||||
const handleMapWidthChange = useCallback((value: number) => {
|
||||
if (tilemap && value > 0) {
|
||||
tilemap.resize(value, tilemap.height, 'bottom-left');
|
||||
onTilemapChange();
|
||||
}
|
||||
}, [tilemap, onTilemapChange]);
|
||||
|
||||
const handleMapHeightChange = useCallback((value: number) => {
|
||||
if (tilemap && value > 0) {
|
||||
tilemap.resize(tilemap.width, value, 'bottom-left');
|
||||
onTilemapChange();
|
||||
}
|
||||
}, [tilemap, onTilemapChange]);
|
||||
|
||||
const handleTileWidthChange = useCallback((value: number) => {
|
||||
if (tilemap && value > 0) {
|
||||
tilemap.tileWidth = value;
|
||||
onTilemapChange();
|
||||
}
|
||||
}, [tilemap, onTilemapChange]);
|
||||
|
||||
const handleTileHeightChange = useCallback((value: number) => {
|
||||
if (tilemap && value > 0) {
|
||||
tilemap.tileHeight = value;
|
||||
onTilemapChange();
|
||||
}
|
||||
}, [tilemap, onTilemapChange]);
|
||||
|
||||
if (!tilemap) {
|
||||
return (
|
||||
<div className="tilemap-details-panel">
|
||||
<div className="details-empty">
|
||||
未选择瓦片地图
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tilemap-details-panel">
|
||||
<div className="details-header">
|
||||
<span className="details-header-title">细节</span>
|
||||
<div className="details-header-actions">
|
||||
<div className="details-search-inline">
|
||||
<Search size={12} />
|
||||
<input type="text" placeholder="搜索" />
|
||||
</div>
|
||||
<button className="details-settings-btn" title="设置">
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="details-content">
|
||||
{/* Tile Map Section */}
|
||||
<Section title="瓦片地图">
|
||||
<div className="property-row">
|
||||
<label>瓦片层列表</label>
|
||||
<span className="layer-count-badge">图层 {currentLayer + 1}</span>
|
||||
</div>
|
||||
|
||||
{/* Tile Layers List */}
|
||||
<div className="layer-list-container">
|
||||
{layers.map((layer, index) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`layer-list-item ${index === currentLayer ? 'selected' : ''}`}
|
||||
onClick={() => handleLayerSelect(index)}
|
||||
>
|
||||
<button
|
||||
className={`layer-visibility-btn ${layer.visible ? '' : 'hidden'}`}
|
||||
onClick={(e) => handleLayerVisibilityToggle(index, e)}
|
||||
title={layer.visible ? '隐藏图层' : '显示图层'}
|
||||
>
|
||||
{layer.visible ? <Eye size={12} /> : <EyeOff size={12} />}
|
||||
</button>
|
||||
<span className="layer-icon">◆</span>
|
||||
<span>{layer.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Layer action buttons */}
|
||||
<div className="layer-actions-row">
|
||||
<button
|
||||
className="layer-action-btn add"
|
||||
onClick={onAddLayer}
|
||||
title="添加图层"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="layer-action-btn"
|
||||
onClick={() => currentLayer > 0 && onMoveLayer(currentLayer, currentLayer - 1)}
|
||||
disabled={currentLayer <= 0}
|
||||
title="上移"
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="layer-action-btn"
|
||||
onClick={() => currentLayer < layers.length - 1 && onMoveLayer(currentLayer, currentLayer + 1)}
|
||||
disabled={currentLayer >= layers.length - 1}
|
||||
title="下移"
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="layer-action-btn"
|
||||
title="复制"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="layer-action-btn danger"
|
||||
onClick={() => layers.length > 1 && onRemoveLayer(currentLayer)}
|
||||
disabled={layers.length <= 1}
|
||||
title="删除图层"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Selected Layer Section */}
|
||||
<Section title="选定层">
|
||||
<PropertyRow label="">
|
||||
<span className="selected-layer-name">{selectedLayer?.name || '图层 1'}</span>
|
||||
</PropertyRow>
|
||||
<ToggleProperty
|
||||
label="编辑器中隐藏"
|
||||
checked={hiddenInEditor}
|
||||
onChange={handleHiddenInEditorChange}
|
||||
/>
|
||||
<ToggleProperty
|
||||
label="游戏中隐藏"
|
||||
checked={hiddenInGame}
|
||||
onChange={setHiddenInGame}
|
||||
/>
|
||||
<ToggleProperty
|
||||
label="图层碰撞"
|
||||
checked={layerCollides}
|
||||
onChange={setLayerCollides}
|
||||
/>
|
||||
<ToggleProperty
|
||||
label="重载碰撞厚度"
|
||||
checked={overrideCollisionThickness}
|
||||
onChange={setOverrideCollisionThickness}
|
||||
indent
|
||||
/>
|
||||
<ToggleProperty
|
||||
label="重载碰撞偏移"
|
||||
checked={overrideCollisionOffset}
|
||||
onChange={setOverrideCollisionOffset}
|
||||
indent
|
||||
/>
|
||||
{overrideCollisionThickness && (
|
||||
<NumberProperty
|
||||
label="碰撞厚度重载"
|
||||
value={collisionThickness}
|
||||
onChange={setCollisionThickness}
|
||||
/>
|
||||
)}
|
||||
{overrideCollisionOffset && (
|
||||
<NumberProperty
|
||||
label="碰撞偏移重载"
|
||||
value={collisionOffset}
|
||||
onChange={setCollisionOffset}
|
||||
/>
|
||||
)}
|
||||
<ColorProperty
|
||||
label="图层颜色"
|
||||
value={layerColor}
|
||||
onChange={setLayerColor}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Setup Section */}
|
||||
<Section title="配置">
|
||||
<NumberProperty
|
||||
label="地图宽度"
|
||||
value={tilemap.width}
|
||||
onChange={handleMapWidthChange}
|
||||
min={1}
|
||||
/>
|
||||
<NumberProperty
|
||||
label="地图高度"
|
||||
value={tilemap.height}
|
||||
onChange={handleMapHeightChange}
|
||||
min={1}
|
||||
/>
|
||||
<NumberProperty
|
||||
label="瓦片宽度"
|
||||
value={tilemap.tileWidth}
|
||||
onChange={handleTileWidthChange}
|
||||
min={1}
|
||||
/>
|
||||
<NumberProperty
|
||||
label="瓦片高度"
|
||||
value={tilemap.tileHeight}
|
||||
onChange={handleTileHeightChange}
|
||||
min={1}
|
||||
/>
|
||||
<NumberProperty
|
||||
label="逻辑单位像素"
|
||||
value={1.0}
|
||||
onChange={() => {}}
|
||||
step={0.1}
|
||||
/>
|
||||
<NumberProperty
|
||||
label="逐图层分隔"
|
||||
value={4.0}
|
||||
onChange={() => {}}
|
||||
step={0.1}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Material Section */}
|
||||
<Section title="材质" defaultOpen={false}>
|
||||
<PropertyRow label="材质">
|
||||
<button className="asset-dropdown">
|
||||
<span>Masked</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
</PropertyRow>
|
||||
</Section>
|
||||
|
||||
{/* Advanced Section */}
|
||||
<Section title="高级" defaultOpen={false}>
|
||||
<PropertyRow label="投射模式">
|
||||
<select defaultValue="orthogonal">
|
||||
<option value="orthogonal">正交</option>
|
||||
<option value="isometric">等轴测</option>
|
||||
<option value="hexagonal">六方</option>
|
||||
</select>
|
||||
</PropertyRow>
|
||||
<NumberProperty
|
||||
label="六方格边长度"
|
||||
value={0}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<ColorProperty
|
||||
label="背景颜色"
|
||||
value={tileGridColor}
|
||||
onChange={setTileGridColor}
|
||||
showReset
|
||||
/>
|
||||
<ColorProperty
|
||||
label="瓦片网格颜色"
|
||||
value={tileGridColor}
|
||||
onChange={setTileGridColor}
|
||||
showReset
|
||||
/>
|
||||
<ColorProperty
|
||||
label="多瓦片网格颜色"
|
||||
value={multiTileGridColor}
|
||||
onChange={setMultiTileGridColor}
|
||||
showReset
|
||||
/>
|
||||
<NumberProperty
|
||||
label="多瓦片网格宽度"
|
||||
value={0}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Collision Section */}
|
||||
<Section title="碰撞" defaultOpen={false}>
|
||||
<ToggleProperty
|
||||
label="显示碰撞"
|
||||
checked={showCollision}
|
||||
onChange={setShowCollision}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TilemapDetailsPanel;
|
||||
1216
packages/tilemap-editor/src/components/panels/TilemapEditorPanel.tsx
Normal file
1216
packages/tilemap-editor/src/components/panels/TilemapEditorPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
150
packages/tilemap-editor/src/components/panels/TilesetPanel.tsx
Normal file
150
packages/tilemap-editor/src/components/panels/TilesetPanel.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Tileset Panel - Display tileset for selection
|
||||
*/
|
||||
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { TilemapComponent, type ITilesetData } from '@esengine/tilemap';
|
||||
import { useTilemapEditorStore } from '../../stores/TilemapEditorStore';
|
||||
import { TilesetPreview } from '../TilesetPreview';
|
||||
import '../../styles/TilemapEditor.css';
|
||||
|
||||
// Helper to convert file path to URL
|
||||
function convertFileSrc(path: string): string {
|
||||
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('asset://')) {
|
||||
return path;
|
||||
}
|
||||
return `asset://localhost/${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
interface TilesetPanelProps {
|
||||
projectPath?: string | null;
|
||||
}
|
||||
|
||||
export const TilesetPanel: React.FC<TilesetPanelProps> = () => {
|
||||
const {
|
||||
entityId,
|
||||
tilesetImageUrl,
|
||||
tilesetColumns,
|
||||
tilesetRows,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
selectedTiles,
|
||||
setTileset
|
||||
} = useTilemapEditorStore();
|
||||
|
||||
// Load tileset from component
|
||||
const loadTilesetFromComponent = useCallback(() => {
|
||||
if (!entityId) return;
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
const foundEntity = scene.findEntityById(parseInt(entityId, 10));
|
||||
if (!foundEntity) return;
|
||||
|
||||
const tilemapComp = foundEntity.getComponent(TilemapComponent);
|
||||
if (!tilemapComp) return;
|
||||
|
||||
// Get tileset source from first tileset
|
||||
const tilesetRef = tilemapComp.tilesets[0];
|
||||
if (!tilesetRef) return;
|
||||
|
||||
const tilesetPath = tilesetRef.source;
|
||||
const imageUrl = convertFileSrc(tilesetPath);
|
||||
const currentState = useTilemapEditorStore.getState();
|
||||
|
||||
// Check if URL or tile dimensions changed
|
||||
const urlChanged = imageUrl !== currentState.tilesetImageUrl;
|
||||
const dimensionsChanged =
|
||||
tilemapComp.tileWidth !== currentState.tileWidth ||
|
||||
tilemapComp.tileHeight !== currentState.tileHeight;
|
||||
|
||||
if (!urlChanged && !dimensionsChanged) return;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const columns = Math.floor(img.width / tilemapComp.tileWidth);
|
||||
const rows = Math.floor(img.height / tilemapComp.tileHeight);
|
||||
|
||||
// Create tileset data and set it
|
||||
const tilesetData: ITilesetData = {
|
||||
name: 'tileset',
|
||||
version: 1,
|
||||
image: tilesetPath,
|
||||
imageWidth: img.width,
|
||||
imageHeight: img.height,
|
||||
tileWidth: tilemapComp.tileWidth,
|
||||
tileHeight: tilemapComp.tileHeight,
|
||||
tileCount: columns * rows,
|
||||
columns,
|
||||
rows
|
||||
};
|
||||
tilemapComp.setTilesetData(0, tilesetData);
|
||||
setTileset(imageUrl, columns, rows, tilemapComp.tileWidth, tilemapComp.tileHeight);
|
||||
};
|
||||
img.src = imageUrl;
|
||||
}, [entityId, setTileset]);
|
||||
|
||||
// Load tileset when entityId is set but tilesetImageUrl is not yet loaded
|
||||
useEffect(() => {
|
||||
if (!entityId || tilesetImageUrl) return;
|
||||
loadTilesetFromComponent();
|
||||
}, [entityId, tilesetImageUrl, loadTilesetFromComponent]);
|
||||
|
||||
// Listen for scene modifications to reload tileset when property changes
|
||||
useEffect(() => {
|
||||
if (!entityId) return;
|
||||
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (!messageHub) return;
|
||||
|
||||
const unsubscribe = messageHub.subscribe('scene:modified', () => {
|
||||
loadTilesetFromComponent();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [entityId, loadTilesetFromComponent]);
|
||||
|
||||
if (!tilesetImageUrl) {
|
||||
return (
|
||||
<div className="tileset-panel">
|
||||
<div className="tileset-panel-header">
|
||||
<h3>Tileset</h3>
|
||||
</div>
|
||||
<div className="tileset-empty">
|
||||
<p>
|
||||
No tileset loaded.
|
||||
<br />
|
||||
Select a TilemapComponent to edit.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tileset-panel">
|
||||
<div className="tileset-panel-header">
|
||||
<h3>Tileset</h3>
|
||||
</div>
|
||||
<div className="tileset-canvas-container">
|
||||
<TilesetPreview
|
||||
imageUrl={tilesetImageUrl}
|
||||
tileWidth={tileWidth}
|
||||
tileHeight={tileHeight}
|
||||
columns={tilesetColumns}
|
||||
rows={tilesetRows}
|
||||
/>
|
||||
</div>
|
||||
{selectedTiles && (
|
||||
<div className="tilemap-info-bar">
|
||||
<span>
|
||||
Selected: {selectedTiles.width}×{selectedTiles.height}
|
||||
</span>
|
||||
<span>Tile: {selectedTiles.tiles[0]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user