Files
esengine/packages/tilemap-editor/src/components/TilesetPreview.tsx
YHH 63f006ab62 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检测到的代码问题
2025-12-03 22:15:22 +08:00

307 lines
10 KiB
TypeScript

/**
* 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';
import type { ITilesetData, ITileAnimation } from '@esengine/tilemap';
interface TilesetPreviewProps {
imageUrl: string;
tileWidth: number;
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> = ({
imageUrl,
tileWidth,
tileHeight,
columns,
rows,
tileset,
animatedTileIds,
onSelectionChange,
onEditAnimation,
}) => {
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 [contextMenu, setContextMenu] = useState<{ x: number; y: number; tileId: number } | null>(null);
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
);
}
// 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();
}, [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);
};
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}
>
<canvas
ref={canvasRef}
className="tileset-canvas"
style={{
transform: `scale(${zoom})`,
transformOrigin: 'top left',
}}
onMouseDown={handleMouseDown}
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>
);
};