From e1d494b415c48c2245384274f68d91d6257789d1 Mon Sep 17 00:00:00 2001 From: yhh <359807859@qq.com> Date: Wed, 3 Dec 2025 16:20:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(tilemap):=20=E5=A2=9E=E5=BC=BAtilemap?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E5=92=8C=E5=8A=A8=E7=94=BB=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tilemap-editor/package.json | 3 + .../src/components/TilemapCanvas.tsx | 79 +++- .../src/components/TilemapViewport.tsx | 388 ++++++++++++++++++ .../src/components/TilesetPreview.tsx | 98 ++++- .../components/panels/TileAnimationEditor.tsx | 367 +++++++++++++++++ .../panels/TileSetSelectorPanel.tsx | 133 +++++- .../components/panels/TilemapDetailsPanel.tsx | 251 ++++++++++- .../components/panels/TilemapEditorPanel.tsx | 289 ++++++++----- packages/tilemap-editor/src/index.ts | 34 +- .../src/stores/TilemapEditorStore.ts | 22 + .../src/styles/TileAnimationEditor.css | 339 +++++++++++++++ .../src/styles/TileSetSelectorPanel.css | 86 ++++ .../src/styles/TilemapDetailsPanel.css | 204 +++++++++ packages/tilemap-editor/src/tools/FillTool.ts | 51 +++ .../tilemap-editor/src/tools/RectangleTool.ts | 100 +++++ .../tilemap-editor/src/tools/SelectTool.ts | 94 +++++ packages/tilemap/module.json | 44 ++ .../tilemap/src/TilemapAnimationSystem.ts | 207 ++++++++++ packages/tilemap/src/TilemapComponent.ts | 212 +++++++++- packages/tilemap/src/TilemapRuntimeModule.ts | 23 +- packages/tilemap/src/index.ts | 5 +- packages/tilemap/src/loaders/TilemapLoader.ts | 87 +--- packages/tilemap/src/loaders/TilesetLoader.ts | 108 ++--- .../src/systems/TilemapRenderingSystem.ts | 349 ++++++++++------ 24 files changed, 3116 insertions(+), 457 deletions(-) create mode 100644 packages/tilemap-editor/src/components/TilemapViewport.tsx create mode 100644 packages/tilemap-editor/src/components/panels/TileAnimationEditor.tsx create mode 100644 packages/tilemap-editor/src/styles/TileAnimationEditor.css create mode 100644 packages/tilemap-editor/src/tools/RectangleTool.ts create mode 100644 packages/tilemap-editor/src/tools/SelectTool.ts create mode 100644 packages/tilemap/module.json create mode 100644 packages/tilemap/src/TilemapAnimationSystem.ts diff --git a/packages/tilemap-editor/package.json b/packages/tilemap-editor/package.json index ade22014..0882c8b7 100644 --- a/packages/tilemap-editor/package.json +++ b/packages/tilemap-editor/package.json @@ -24,6 +24,9 @@ "dependencies": { "@esengine/tilemap": "workspace:*" }, + "peerDependencies": { + "@esengine/editor-core": "workspace:*" + }, "devDependencies": { "@esengine/ecs-framework": "workspace:*", "@esengine/engine-core": "workspace:*", diff --git a/packages/tilemap-editor/src/components/TilemapCanvas.tsx b/packages/tilemap-editor/src/components/TilemapCanvas.tsx index 1ae45435..28cea044 100644 --- a/packages/tilemap-editor/src/components/TilemapCanvas.tsx +++ b/packages/tilemap-editor/src/components/TilemapCanvas.tsx @@ -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 = { brush: new BrushTool(), eraser: new EraserTool(), fill: new FillTool(), + rectangle: new RectangleTool(), + select: new SelectTool(), }; export const TilemapCanvas: React.FC = ({ @@ -57,9 +62,12 @@ export const TilemapCanvas: React.FC = ({ 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(0); + const animationFrameRef = useRef(null); // Get canvas size const canvasWidth = tilemap.width * tileWidth; @@ -104,9 +112,16 @@ export const TilemapCanvas: React.FC = ({ 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 +197,7 @@ export const TilemapCanvas: React.FC = ({ } 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(() => { @@ -223,6 +238,44 @@ export const TilemapCanvas: React.FC = ({ 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 +341,7 @@ export const TilemapCanvas: React.FC = ({ // 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 +366,7 @@ export const TilemapCanvas: React.FC = ({ }; tool.onMouseDown(tileX, tileY, toolContext); onTilemapChange?.(); - draw(); + // draw() 由 useEffect 统一处理,避免重复绘制导致闪烁 } }; @@ -326,10 +379,11 @@ export const TilemapCanvas: React.FC = ({ // 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 +408,7 @@ export const TilemapCanvas: React.FC = ({ onTilemapChange?.(); } } - - draw(); + // draw() 由 setMousePos 触发的 useEffect 统一处理 }; const handleMouseUp = (e: React.MouseEvent) => { @@ -389,7 +442,7 @@ export const TilemapCanvas: React.FC = ({ const handleMouseLeave = () => { setMousePos(null); - draw(); + // draw() 由 setMousePos 触发的 useEffect 统一处理 }; const handleWheel = (e: React.WheelEvent) => { diff --git a/packages/tilemap-editor/src/components/TilemapViewport.tsx b/packages/tilemap-editor/src/components/TilemapViewport.tsx new file mode 100644 index 00000000..a1fd6c8f --- /dev/null +++ b/packages/tilemap-editor/src/components/TilemapViewport.tsx @@ -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 = { + brush: new BrushTool(), + eraser: new EraserTool(), + fill: new FillTool(), +}; + +export const TilemapViewport: React.FC = ({ + tilemap, + onTilemapChange, +}) => { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const viewportServiceRef = useRef(null); + const registeredRef = useRef(false); + + const { + currentTool, + zoom, + panX, + panY, + showGrid, + 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 + const canvasWidth = tilemap.width * tileWidth; + const canvasHeight = tilemap.height * tileHeight; + + // Initialize viewport service + useEffect(() => { + const service = Core.services.tryResolve(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 ( +
+ e.preventDefault()} + style={{ cursor: getCursor() }} + /> +
+ ); +}; diff --git a/packages/tilemap-editor/src/components/TilesetPreview.tsx b/packages/tilemap-editor/src/components/TilesetPreview.tsx index f06e8791..91fef26e 100644 --- a/packages/tilemap-editor/src/components/TilesetPreview.tsx +++ b/packages/tilemap-editor/src/components/TilesetPreview.tsx @@ -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; onSelectionChange?: (selection: TileSelection) => void; + onEditAnimation?: (tileId: number) => void; } export const TilesetPreview: React.FC = ({ @@ -20,7 +24,10 @@ export const TilesetPreview: React.FC = ({ tileHeight, columns, rows, + tileset, + animatedTileIds, onSelectionChange, + onEditAnimation, }) => { const canvasRef = useRef(null); const containerRef = useRef(null); @@ -29,6 +36,7 @@ export const TilesetPreview: React.FC = ({ 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 = ({ 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 = ({ 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 (
@@ -204,7 +264,43 @@ export const TilesetPreview: React.FC = ({ onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} + onContextMenu={handleContextMenu} /> + {contextMenu && ( +
+ +
+ )}
); }; diff --git a/packages/tilemap-editor/src/components/panels/TileAnimationEditor.tsx b/packages/tilemap-editor/src/components/panels/TileAnimationEditor.tsx new file mode 100644 index 00000000..cb2697e2 --- /dev/null +++ b/packages/tilemap-editor/src/components/panels/TileAnimationEditor.tsx @@ -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 = ({ + tileId, + tileset, + tilesetImage, + animation, + onAnimationChange, + onClose +}) => { + const [frames, setFrames] = useState( + animation?.frames ?? [] + ); + const [defaultDuration, setDefaultDuration] = useState(100); + const [isPlaying, setIsPlaying] = useState(true); + const [currentPreviewFrame, setCurrentPreviewFrame] = useState(0); + const [dragOverIndex, setDragOverIndex] = useState(null); + + const previewCanvasRef = useRef(null); + const tilesetCanvasRef = useRef(null); + const animationTimerRef = useRef(null); + const lastFrameTimeRef = useRef(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) => { + 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 ( +
+
+
+

瓦片动画编辑器 - 瓦片 #{tileId}

+ +
+ +
+ {/* Preview section */} +
+
+ +
+
+ + + {frames.length > 0 ? `${currentPreviewFrame + 1}/${frames.length}` : '无帧'} + +
+
+ + {/* Frame list */} +
+
+ 动画帧 + {frames.length} 帧 +
+
+ {frames.map((frame, index) => ( +
handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, index)} + > +
+ +
+
+ { + if (canvas && tilesetImage) { + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.clearRect(0, 0, 32, 32); + drawTile(ctx, frame.tileId, 0, 0, 32, 32); + } + } + }} + /> +
+
+ #{frame.tileId} + handleDurationChange(index, parseInt(e.target.value, 10) || 100)} + min={10} + step={10} + /> + ms +
+ +
+ ))} + {frames.length === 0 && ( +
+ 点击下方瓦片添加动画帧 +
+ )} +
+
+
+ + {/* Tileset selector */} +
+
+ 点击瓦片添加帧 +
+ + setDefaultDuration(parseInt(e.target.value, 10) || 100)} + min={10} + step={10} + /> + ms +
+
+
+ +
+
+ + {/* Footer buttons */} +
+ +
+ + +
+
+
+
+ ); +}; + +export default TileAnimationEditor; diff --git a/packages/tilemap-editor/src/components/panels/TileSetSelectorPanel.tsx b/packages/tilemap-editor/src/components/panels/TileSetSelectorPanel.tsx index 84222c70..392690a5 100644 --- a/packages/tilemap-editor/src/components/panels/TileSetSelectorPanel.tsx +++ b/packages/tilemap-editor/src/components/panels/TileSetSelectorPanel.tsx @@ -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 = ({ tilesets, activeTilesetIndex, + activeTileset, + tilesetImage, onTilesetChange, - onAddTileset + onAddTileset, + onTileAnimationChange }) => { const { currentTool, @@ -35,26 +43,93 @@ export const TileSetSelectorPanel: React.FC = ({ tileHeight, tilesetColumns, tilesetRows, - selectedTiles + selectedTiles, + editingCollision, + setEditingCollision } = useTilemapEditorStore(); const [showTilesetDropdown, setShowTilesetDropdown] = useState(false); const [previewZoom, setPreviewZoom] = useState(1); + const [editingAnimationTileId, setEditingAnimationTileId] = useState(null); + + // Get animated tile IDs from tileset + const animatedTileIds = useMemo(() => { + const ids = new Set(); + 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 (
+ {/* Mode toggle */} +
+ + +
+ {/* Tool buttons */}
+ +
{/* Active Tile Set selector */} @@ -95,7 +186,7 @@ export const TileSetSelectorPanel: React.FC = ({ className="tileset-dropdown-btn" onClick={() => setShowTilesetDropdown(!showTilesetDropdown)} > - {activeTileset?.name || '(无)'} + {activeTilesetOption?.name || '(无)'} {showTilesetDropdown && ( @@ -144,13 +235,23 @@ export const TileSetSelectorPanel: React.FC = ({ {/* Tile preview area */}
- {tilesetImageUrl ? ( + {editingCollision ? ( +
+ + 碰撞编辑模式 + 使用画笔绘制碰撞区域 + 使用橡皮擦清除碰撞 +
+ ) : tilesetImageUrl ? ( ) : (
@@ -167,6 +268,18 @@ export const TileSetSelectorPanel: React.FC = ({ 已选择: {selectedTiles.width}×{selectedTiles.height}
)} + + {/* Animation Editor */} + {editingAnimationTileId !== null && activeTileset && tilesetImage && ( + + )}
); }; diff --git a/packages/tilemap-editor/src/components/panels/TilemapDetailsPanel.tsx b/packages/tilemap-editor/src/components/panels/TilemapDetailsPanel.tsx index 9f9fc177..a3087401 100644 --- a/packages/tilemap-editor/src/components/panels/TilemapDetailsPanel.tsx +++ b/packages/tilemap-editor/src/components/panels/TilemapDetailsPanel.tsx @@ -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 = ({ ); +// 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 = ({ + label, + value, + onChange, + min = 0, + max = 1, + step = 0.01 +}) => ( + +
+ onChange(parseFloat(e.target.value))} + min={min} + max={max} + step={step} + /> + {Math.round(value * 100)}% +
+
+); + // Color property - unified style matching PropertyInspector interface ColorPropertyProps { label: string; @@ -175,13 +213,85 @@ const ColorProperty: React.FC = ({ 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 = ({ label, value, onSelect, onClear }) => { + const getFileName = (path: string) => { + const parts = path.split(/[\\/]/); + return parts[parts.length - 1].replace('.mat', '').replace('.json', ''); + }; + + return ( +
+ +
+ {/* Thumbnail */} +
+ +
+ + {/* Right side */} +
+ {/* Dropdown */} +
+ + {value ? getFileName(value) : '默认材质'} + + +
+ + {/* Actions */} +
+ {value && ( + <> + + + + )} +
+
+
+
+ ); +}; + export const TilemapDetailsPanel: React.FC = ({ tilemap, onAddLayer, onRemoveLayer, onMoveLayer, + onDuplicateLayer, onTilemapChange, - onOpenAssetPicker + onOpenAssetPicker, + onSelectLayerMaterial }) => { const { layers, @@ -189,19 +299,24 @@ export const TilemapDetailsPanel: React.FC = ({ 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,7 +345,71 @@ export const TilemapDetailsPanel: React.FC = ({ } }, [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'); @@ -348,7 +527,8 @@ export const TilemapDetailsPanel: React.FC = ({ @@ -365,8 +545,26 @@ export const TilemapDetailsPanel: React.FC = ({ {/* Selected Layer Section */}
- - {selectedLayer?.name || '图层 1'} + + {isEditingName ? ( + setEditingName(e.target.value)} + onBlur={handleFinishEditName} + onKeyDown={handleNameKeyDown} + autoFocus + /> + ) : ( + + {selectedLayer?.name || '图层 1'} + + )} = ({ /> + = ({ )}
@@ -457,13 +663,18 @@ export const TilemapDetailsPanel: React.FC = ({ {/* Material Section */} -
- - - +
+
+ onSelectLayerMaterial?.(currentLayer)} + onClear={() => { + tilemap.setLayerMaterial(currentLayer, ''); + onTilemapChange(); + }} + /> +
{/* Advanced Section */} diff --git a/packages/tilemap-editor/src/components/panels/TilemapEditorPanel.tsx b/packages/tilemap-editor/src/components/panels/TilemapEditorPanel.tsx index 6898b761..e5934a7e 100644 --- a/packages/tilemap-editor/src/components/panels/TilemapEditorPanel.tsx +++ b/packages/tilemap-editor/src/components/panels/TilemapEditorPanel.tsx @@ -11,28 +11,17 @@ 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'; @@ -596,12 +585,9 @@ export const TilemapEditorPanel: React.FC = ({ 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); @@ -630,7 +616,10 @@ export const TilemapEditorPanel: React.FC = ({ messageH setPan, setTileset, setLayers, - setCurrentLayer + setCurrentLayer, + currentLayer, + undo, + redo } = useTilemapEditorStore(); // Load tileset from component (defined early for use in effects) @@ -721,7 +710,9 @@ export const TilemapEditorPanel: React.FC = ({ 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 +779,9 @@ export const TilemapEditorPanel: React.FC = ({ 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 +793,6 @@ export const TilemapEditorPanel: React.FC = ({ messageH const unsubscribeModified = messageHub.subscribe('scene:modified', () => { loadTilesetFromComponent(tilemap); - setTilemapKey(`${tilemap.width}-${tilemap.height}-${Date.now()}`); }); const unsubscribeRestored = messageHub.subscribe('scene:restored', () => { @@ -840,16 +832,61 @@ export const TilemapEditorPanel: React.FC = ({ 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 +922,65 @@ export const TilemapEditorPanel: React.FC = ({ 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)); @@ -922,7 +1003,9 @@ export const TilemapEditorPanel: React.FC = ({ 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 +1021,9 @@ export const TilemapEditorPanel: React.FC = ({ 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 +1043,9 @@ export const TilemapEditorPanel: React.FC = ({ 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 +1053,26 @@ export const TilemapEditorPanel: React.FC = ({ 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 +1102,18 @@ export const TilemapEditorPanel: React.FC = ({ 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 +1144,11 @@ export const TilemapEditorPanel: React.FC = ({ messageH
@@ -1038,69 +1160,6 @@ export const TilemapEditorPanel: React.FC = ({ messageH {/* Viewport top toolbar */}
- {/* View mode buttons */} -
- - - -
-
- -
- {/* Transform tools */} -
- - - - -
- -
- - {/* Grid/snap controls */}
- - -
+
+ +
+
- {/* Zoom controls */}
- {/* Canvas */} + {/* Viewport */}
= ({ messageH onAddLayer={handleAddLayer} onRemoveLayer={handleRemoveLayer} onMoveLayer={handleMoveLayer} + onDuplicateLayer={handleDuplicateLayer} onTilemapChange={handleTilemapChange} onOpenAssetPicker={() => setShowAssetPicker(true)} + onSelectLayerMaterial={handleSelectLayerMaterial} />
@@ -1204,6 +1266,15 @@ export const TilemapEditorPanel: React.FC = ({ messageH fileExtensions={['.png', '.jpg', '.jpeg', '.webp']} /> + {/* Material Picker Dialog */} + setShowMaterialPicker(false)} + onSelect={handleMaterialSelected} + title="选择图层材质" + fileExtensions={['.mat', '.mat.json']} + /> + setShowResizeDialog(false)} diff --git a/packages/tilemap-editor/src/index.ts b/packages/tilemap-editor/src/index.ts index c227dd47..de76fe8b 100644 --- a/packages/tilemap-editor/src/index.ts +++ b/packages/tilemap-editor/src/index.ts @@ -32,7 +32,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 +46,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 +54,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 +359,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 +386,7 @@ const descriptor: PluginDescriptor = { * Complete Tilemap Plugin (runtime + editor) */ export const TilemapPlugin: IPlugin = { - descriptor, + manifest, runtimeModule: new TilemapRuntimeModule(), editorModule: tilemapEditorModule }; diff --git a/packages/tilemap-editor/src/stores/TilemapEditorStore.ts b/packages/tilemap-editor/src/stores/TilemapEditorStore.ts index 1394c4da..fbab1a07 100644 --- a/packages/tilemap-editor/src/stores/TilemapEditorStore.ts +++ b/packages/tilemap-editor/src/stores/TilemapEditorStore.ts @@ -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((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]; diff --git a/packages/tilemap-editor/src/styles/TileAnimationEditor.css b/packages/tilemap-editor/src/styles/TileAnimationEditor.css new file mode 100644 index 00000000..d93b5b76 --- /dev/null +++ b/packages/tilemap-editor/src/styles/TileAnimationEditor.css @@ -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; +} diff --git a/packages/tilemap-editor/src/styles/TileSetSelectorPanel.css b/packages/tilemap-editor/src/styles/TileSetSelectorPanel.css index ad9e48c9..17e88ad9 100644 --- a/packages/tilemap-editor/src/styles/TileSetSelectorPanel.css +++ b/packages/tilemap-editor/src/styles/TileSetSelectorPanel.css @@ -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; diff --git a/packages/tilemap-editor/src/styles/TilemapDetailsPanel.css b/packages/tilemap-editor/src/styles/TilemapDetailsPanel.css index a575fc01..fd7af8f4 100644 --- a/packages/tilemap-editor/src/styles/TilemapDetailsPanel.css +++ b/packages/tilemap-editor/src/styles/TilemapDetailsPanel.css @@ -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 { diff --git a/packages/tilemap-editor/src/tools/FillTool.ts b/packages/tilemap-editor/src/tools/FillTool.ts index 6482dca6..8aa5af01 100644 --- a/packages/tilemap-editor/src/tools/FillTool.ts +++ b/packages/tilemap-editor/src/tools/FillTool.ts @@ -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(); + + 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(); + + 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; diff --git a/packages/tilemap-editor/src/tools/RectangleTool.ts b/packages/tilemap-editor/src/tools/RectangleTool.ts new file mode 100644 index 00000000..e84d25b2 --- /dev/null +++ b/packages/tilemap-editor/src/tools/RectangleTool.ts @@ -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; + } +} diff --git a/packages/tilemap-editor/src/tools/SelectTool.ts b/packages/tilemap-editor/src/tools/SelectTool.ts new file mode 100644 index 00000000..6f330339 --- /dev/null +++ b/packages/tilemap-editor/src/tools/SelectTool.ts @@ -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; + } +} diff --git a/packages/tilemap/module.json b/packages/tilemap/module.json new file mode 100644 index 00000000..11867234 --- /dev/null +++ b/packages/tilemap/module.json @@ -0,0 +1,44 @@ +{ + "id": "tilemap", + "name": "@esengine/tilemap", + "displayName": "Tilemap 2D", + "description": "2D tilemap rendering and editing | 2D 瓦片地图渲染和编辑", + "version": "1.0.0", + "category": "Rendering", + "icon": "Grid3X3", + "tags": [ + "2d", + "tilemap", + "tiled" + ], + "isCore": false, + "defaultEnabled": false, + "isEngineModule": true, + "canContainContent": true, + "platforms": [ + "web", + "desktop" + ], + "dependencies": [ + "core", + "math", + "sprite", + "asset-system" + ], + "exports": { + "components": [ + "TilemapComponent" + ], + "systems": [ + "TilemapRenderingSystem" + ], + "loaders": [ + "TilemapLoader", + "TilesetLoader" + ] + }, + "editorPackage": "@esengine/tilemap-editor", + "requiresWasm": false, + "outputPath": "dist/index.js", + "pluginExport": "TilemapPlugin" +} diff --git a/packages/tilemap/src/TilemapAnimationSystem.ts b/packages/tilemap/src/TilemapAnimationSystem.ts new file mode 100644 index 00000000..512e1df9 --- /dev/null +++ b/packages/tilemap/src/TilemapAnimationSystem.ts @@ -0,0 +1,207 @@ +/** + * Tilemap Animation System + * 瓦片地图动画系统 + * + * Manages tile animation playback for all animated tiles in tilesets. + * 管理图块集中所有动画瓦片的动画播放。 + */ + +import type { ITilesetData, ITileMetadata } from './TilemapComponent'; + +/** + * Animation state for a single animated tile + * 单个动画瓦片的动画状态 + */ +interface TileAnimationState { + /** Current frame index | 当前帧索引 */ + currentFrame: number; + /** Elapsed time since last frame change (ms) | 自上次帧变化以来的时间(毫秒) */ + elapsedTime: number; +} + +/** + * Tilemap Animation System + * 瓦片地图动画系统 + */ +export class TilemapAnimationSystem { + /** Animation states keyed by "tilesetIndex:tileId" | 按"图块集索引:瓦片ID"索引的动画状态 */ + private animationStates: Map = new Map(); + + /** Cached animated tile metadata for quick lookup | 缓存的动画瓦片元数据用于快速查找 */ + private animatedTiles: Map = new Map(); + + /** Whether animations are playing | 动画是否正在播放 */ + private _isPlaying: boolean = true; + + /** + * Register a tileset's animated tiles + * 注册图块集的动画瓦片 + */ + registerTileset(tilesetIndex: number, tileset: ITilesetData): void { + if (!tileset.tiles) return; + + for (const tile of tileset.tiles) { + if (tile.animation && tile.animation.frames.length > 0) { + const key = `${tilesetIndex}:${tile.id}`; + this.animatedTiles.set(key, tile); + this.animationStates.set(key, { + currentFrame: 0, + elapsedTime: 0 + }); + } + } + } + + /** + * Unregister a tileset + * 注销图块集 + */ + unregisterTileset(tilesetIndex: number): void { + const keysToRemove: string[] = []; + for (const key of this.animationStates.keys()) { + if (key.startsWith(`${tilesetIndex}:`)) { + keysToRemove.push(key); + } + } + for (const key of keysToRemove) { + this.animationStates.delete(key); + this.animatedTiles.delete(key); + } + } + + /** + * Clear all animation states + * 清除所有动画状态 + */ + clear(): void { + this.animationStates.clear(); + this.animatedTiles.clear(); + } + + /** + * Update all animations + * 更新所有动画 + * @param deltaTime Time since last update in milliseconds | 自上次更新以来的时间(毫秒) + */ + update(deltaTime: number): void { + if (!this._isPlaying) return; + + for (const [key, state] of this.animationStates) { + const tile = this.animatedTiles.get(key); + if (!tile?.animation) continue; + + const frames = tile.animation.frames; + const currentFrame = frames[state.currentFrame]; + if (!currentFrame) continue; + + state.elapsedTime += deltaTime; + + // Advance frames while elapsed time exceeds frame duration + while (state.elapsedTime >= currentFrame.duration) { + state.elapsedTime -= currentFrame.duration; + state.currentFrame = (state.currentFrame + 1) % frames.length; + } + } + } + + /** + * Get the current display tile ID for an animated tile + * 获取动画瓦片的当前显示瓦片ID + * @param tilesetIndex Tileset index | 图块集索引 + * @param tileId Original tile ID | 原始瓦片ID + * @returns Current frame's tile ID, or original if not animated | 当前帧的瓦片ID,如果不是动画则返回原始ID + */ + getCurrentTileId(tilesetIndex: number, tileId: number): number { + const key = `${tilesetIndex}:${tileId}`; + const tile = this.animatedTiles.get(key); + const state = this.animationStates.get(key); + + if (!tile?.animation || !state) { + return tileId; + } + + const frame = tile.animation.frames[state.currentFrame]; + return frame?.tileId ?? tileId; + } + + /** + * Check if a tile has animation + * 检查瓦片是否有动画 + */ + hasAnimation(tilesetIndex: number, tileId: number): boolean { + const key = `${tilesetIndex}:${tileId}`; + return this.animatedTiles.has(key); + } + + /** + * Get animation metadata for a tile + * 获取瓦片的动画元数据 + */ + getAnimation(tilesetIndex: number, tileId: number): ITileMetadata | undefined { + const key = `${tilesetIndex}:${tileId}`; + return this.animatedTiles.get(key); + } + + /** + * Reset animation to first frame + * 重置动画到第一帧 + */ + resetAnimation(tilesetIndex: number, tileId: number): void { + const key = `${tilesetIndex}:${tileId}`; + const state = this.animationStates.get(key); + if (state) { + state.currentFrame = 0; + state.elapsedTime = 0; + } + } + + /** + * Reset all animations to first frame + * 重置所有动画到第一帧 + */ + resetAll(): void { + for (const state of this.animationStates.values()) { + state.currentFrame = 0; + state.elapsedTime = 0; + } + } + + /** + * Play/pause animations + * 播放/暂停动画 + */ + get isPlaying(): boolean { + return this._isPlaying; + } + + set isPlaying(value: boolean) { + this._isPlaying = value; + } + + /** + * Toggle play/pause + * 切换播放/暂停 + */ + togglePlayback(): boolean { + this._isPlaying = !this._isPlaying; + return this._isPlaying; + } + + /** + * Get all animated tile IDs for a tileset + * 获取图块集的所有动画瓦片ID + */ + getAnimatedTileIds(tilesetIndex: number): number[] { + const ids: number[] = []; + for (const key of this.animatedTiles.keys()) { + if (key.startsWith(`${tilesetIndex}:`)) { + const tileId = parseInt(key.split(':')[1], 10); + ids.push(tileId); + } + } + return ids; + } +} + +/** Global animation system instance | 全局动画系统实例 */ +export const tilemapAnimationSystem = new TilemapAnimationSystem(); diff --git a/packages/tilemap/src/TilemapComponent.ts b/packages/tilemap/src/TilemapComponent.ts index 0791f62f..c8c86c1c 100644 --- a/packages/tilemap/src/TilemapComponent.ts +++ b/packages/tilemap/src/TilemapComponent.ts @@ -11,6 +11,41 @@ export type ResizeAnchor = | 'middle-left' | 'center' | 'middle-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'; +/** + * Animation frame definition + * 动画帧定义 + */ +export interface ITileAnimationFrame { + /** Tile ID to display for this frame (local ID within tileset) | 此帧显示的瓦片ID(图块集内的本地ID) */ + tileId: number; + /** Frame duration in milliseconds | 帧持续时间(毫秒) */ + duration: number; +} + +/** + * Tile animation definition + * 瓦片动画定义 + */ +export interface ITileAnimation { + /** Animation frame sequence | 动画帧序列 */ + frames: ITileAnimationFrame[]; +} + +/** + * Individual tile metadata + * 单个瓦片元数据 + */ +export interface ITileMetadata { + /** Tile ID (local ID within tileset) | 瓦片ID(图块集内的本地ID) */ + id: number; + /** Tile class/type | 瓦片类型 */ + type?: string; + /** Custom properties | 自定义属性 */ + properties?: Record; + /** Tile animation (if any) | 瓦片动画(如果有) */ + animation?: ITileAnimation; +} + /** * Tileset data interface * 图块集数据接口 @@ -41,11 +76,7 @@ export interface ITilesetData { /** Spacing between tiles in pixels | 图块间距(像素) */ spacing?: number; /** Individual tile metadata | 单个图块元数据 */ - tiles?: Array<{ - id: number; - type?: string; - properties?: Record; - }>; + tiles?: ITileMetadata[]; } /** @@ -69,6 +100,14 @@ export interface ITilemapLayerData { offsetX?: number; /** Layer Y offset in pixels | 图层Y偏移(像素) */ offsetY?: number; + /** Material asset path for this layer (.mat file) | 此图层的材质资源路径(.mat 文件) */ + materialPath?: string; + /** Runtime material ID (set after loading) | 运行时材质ID(加载后设置) */ + materialId?: number; + /** Tint color in hex format | 着色颜色(十六进制格式) */ + color?: string; + /** Hidden in game (visible only in editor) | 游戏中隐藏(仅在编辑器中可见) */ + hiddenInGame?: boolean; /** Custom layer properties | 自定义图层属性 */ properties?: Record; } @@ -440,6 +479,40 @@ export class TilemapComponent extends Component implements IResourceComponent { return layer; } + /** + * Duplicate a layer + * 复制图层 + * @param index Layer index to duplicate | 要复制的图层索引 + * @returns The duplicated layer data, or null if index is invalid | 复制的图层数据,如果索引无效则返回 null + */ + duplicateLayer(index: number): ITilemapLayerData | null { + if (index < 0 || index >= this._layers.length) { + return null; + } + + const sourceLayer = this._layers[index]; + const sourceData = this._layersData.get(sourceLayer.id); + if (!sourceData) { + return null; + } + + const id = `layer_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + const newLayer: ITilemapLayerData = { + id, + name: `${sourceLayer.name} (副本)`, + visible: sourceLayer.visible, + opacity: sourceLayer.opacity, + data: Array.from(sourceData) + }; + + // Insert after the source layer + this._layers.splice(index + 1, 0, newLayer); + this._layersData.set(id, new Uint32Array(sourceData)); + this.renderDirty = true; + + return newLayer; + } + /** * Remove a layer by index (cannot remove last layer) * 按索引移除图层(不能移除最后一个图层) @@ -527,6 +600,96 @@ export class TilemapComponent extends Component implements IResourceComponent { } } + /** + * Set layer color (tint) + * 设置图层颜色(着色) + */ + setLayerColor(index: number, color: string): void { + if (index >= 0 && index < this._layers.length) { + this._layers[index].color = color; + this.renderDirty = true; + } + } + + /** + * Get layer color + * 获取图层颜色 + */ + getLayerColor(index: number): string { + if (index >= 0 && index < this._layers.length) { + return this._layers[index].color ?? '#ffffff'; + } + return '#ffffff'; + } + + /** + * Set layer hidden in game + * 设置图层在游戏中隐藏 + */ + setLayerHiddenInGame(index: number, hidden: boolean): void { + if (index >= 0 && index < this._layers.length) { + this._layers[index].hiddenInGame = hidden; + } + } + + /** + * Get layer hidden in game + * 获取图层在游戏中是否隐藏 + */ + getLayerHiddenInGame(index: number): boolean { + if (index >= 0 && index < this._layers.length) { + return this._layers[index].hiddenInGame ?? false; + } + return false; + } + + /** + * Set layer material path + * 设置图层材质路径 + * @param index Layer index | 图层索引 + * @param materialPath Material asset path (.mat file) | 材质资源路径(.mat 文件) + */ + setLayerMaterial(index: number, materialPath: string): void { + if (index >= 0 && index < this._layers.length) { + this._layers[index].materialPath = materialPath; + this._layers[index].materialId = undefined; + this.renderDirty = true; + } + } + + /** + * Get layer material path + * 获取图层材质路径 + * @param index Layer index | 图层索引 + * @returns Material path or undefined | 材质路径或 undefined + */ + getLayerMaterial(index: number): string | undefined { + return this._layers[index]?.materialPath; + } + + /** + * Set layer material ID (runtime) + * 设置图层材质ID(运行时) + * @param index Layer index | 图层索引 + * @param materialId Runtime material ID | 运行时材质ID + */ + setLayerMaterialId(index: number, materialId: number): void { + if (index >= 0 && index < this._layers.length) { + this._layers[index].materialId = materialId; + this.renderDirty = true; + } + } + + /** + * Get layer material ID + * 获取图层材质ID + * @param index Layer index | 图层索引 + * @returns Material ID or 0 (default) | 材质ID 或 0(默认) + */ + getLayerMaterialId(index: number): number { + return this._layers[index]?.materialId ?? 0; + } + // ===== Tile Operations | 瓦片操作 ===== /** @@ -591,6 +754,22 @@ export class TilemapComponent extends Component implements IResourceComponent { return this._layersData.get(layer.id); } + /** + * Set raw tile data array for a layer + * 设置图层的原始图块数据数组 + * @param layerIndex Layer index | 图层索引 + * @param data Uint32Array of tile indices | 图块索引的Uint32Array + */ + setLayerData(layerIndex: number, data: Uint32Array): void { + const layer = this._layers[layerIndex]; + if (!layer) return; + + // Copy data to both the layer object and the internal map + layer.data = Array.from(data); + this._layersData.set(layer.id, new Uint32Array(data)); + this.renderDirty = true; + } + /** * Get merged tile data from all visible layers * 获取所有可见图层合并后的图块数据 @@ -1002,6 +1181,18 @@ export class TilemapComponent extends Component implements IResourceComponent { } } + // 收集所有图层材质引用 + // Collect all layer material references + for (const layer of this._layers) { + if (layer.materialPath) { + refs.push({ + path: layer.materialPath, + type: 'data', + runtimeId: layer.materialId + }); + } + } + return refs; } @@ -1021,6 +1212,17 @@ export class TilemapComponent extends Component implements IResourceComponent { } } + // 为每个图层设置材质 ID + // Set material ID for each layer + for (const layer of this._layers) { + if (layer.materialPath) { + const materialId = pathToId.get(layer.materialPath); + if (materialId !== undefined) { + layer.materialId = materialId; + } + } + } + // 标记渲染数据为脏,需要重新构建 // Mark render data as dirty, needs rebuild this.renderDirty = true; diff --git a/packages/tilemap/src/TilemapRuntimeModule.ts b/packages/tilemap/src/TilemapRuntimeModule.ts index 19790e3d..982e508d 100644 --- a/packages/tilemap/src/TilemapRuntimeModule.ts +++ b/packages/tilemap/src/TilemapRuntimeModule.ts @@ -1,6 +1,6 @@ import type { IScene } from '@esengine/ecs-framework'; import { ComponentRegistry } from '@esengine/ecs-framework'; -import type { IRuntimeModule, IPlugin, PluginDescriptor, SystemContext } from '@esengine/engine-core'; +import type { IRuntimeModule, IPlugin, ModuleManifest, SystemContext } from '@esengine/engine-core'; import type { AssetManager } from '@esengine/asset-system'; import { TilemapComponent } from './TilemapComponent'; @@ -63,18 +63,25 @@ class TilemapRuntimeModule implements IRuntimeModule { } } -const descriptor: PluginDescriptor = { - id: '@esengine/tilemap', - name: 'Tilemap', +const manifest: ModuleManifest = { + id: 'tilemap', + name: '@esengine/tilemap', + displayName: 'Tilemap 2D', version: '1.0.0', description: 'Tilemap system with Tiled editor support', - category: 'tilemap', - enabledByDefault: false, - isEnginePlugin: true + category: 'Rendering', + icon: 'Grid3X3', + isCore: false, + defaultEnabled: false, + isEngineModule: true, + canContainContent: true, + dependencies: ['core', 'math', 'sprite', 'asset-system'], + exports: { components: ['TilemapComponent', 'TilemapCollider2DComponent'] }, + editorPackage: '@esengine/tilemap-editor' }; export const TilemapPlugin: IPlugin = { - descriptor, + manifest, runtimeModule: new TilemapRuntimeModule() }; diff --git a/packages/tilemap/src/index.ts b/packages/tilemap/src/index.ts index 49eee765..5b72f491 100644 --- a/packages/tilemap/src/index.ts +++ b/packages/tilemap/src/index.ts @@ -8,9 +8,12 @@ export { TilemapAssetType, TilesetAssetType } from './constants'; // Component export { TilemapComponent } from './TilemapComponent'; -export type { ITilemapData, ITilesetData } from './TilemapComponent'; +export type { ITilemapData, ITilesetData, ITileMetadata, ITileAnimation, ITileAnimationFrame } from './TilemapComponent'; export type { ResizeAnchor } from './TilemapComponent'; +// Animation System +export { TilemapAnimationSystem, tilemapAnimationSystem } from './TilemapAnimationSystem'; + // Systems export { TilemapRenderingSystem } from './systems/TilemapRenderingSystem'; export type { TilemapRenderData, ViewportBounds } from './systems/TilemapRenderingSystem'; diff --git a/packages/tilemap/src/loaders/TilemapLoader.ts b/packages/tilemap/src/loaders/TilemapLoader.ts index 44b9714b..6e282354 100644 --- a/packages/tilemap/src/loaders/TilemapLoader.ts +++ b/packages/tilemap/src/loaders/TilemapLoader.ts @@ -4,11 +4,10 @@ */ import { - IAssetLoadOptions, - IAssetMetadata, - IAssetLoadResult, - AssetLoadError, - IAssetLoader + IAssetLoader, + IAssetContent, + IAssetParseContext, + AssetContentType } from '@esengine/asset-system'; import { TilemapAssetType } from '../constants'; @@ -39,6 +38,8 @@ export interface ITilemapAsset { visible: boolean; opacity: number; data?: number[]; + /** 材质路径 */ + materialPath?: string; }>; /** 碰撞数据(可选) */ collisionData?: number[]; @@ -53,78 +54,26 @@ export interface ITilemapAsset { export class TilemapLoader implements IAssetLoader { readonly supportedType = TilemapAssetType; readonly supportedExtensions = ['.tilemap.json', '.tilemap']; + readonly contentType: AssetContentType = 'text'; /** - * Load tilemap asset - * 加载瓦片地图资产 + * Parse tilemap asset from text content + * 从文本内容解析瓦片地图资产 */ - async load( - path: string, - metadata: IAssetMetadata, - options?: IAssetLoadOptions - ): Promise> { - const startTime = performance.now(); - - try { - const response = await this.fetchWithTimeout(path, options?.timeout); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const jsonData = await response.json() as ITilemapAsset; - - // 验证必要字段 - if (!jsonData.width || !jsonData.height || !jsonData.data) { - throw new Error('Invalid tilemap format: missing required fields'); - } - - return { - asset: jsonData, - handle: 0, - metadata, - loadTime: performance.now() - startTime - }; - } catch (error) { - if (error instanceof Error) { - throw new AssetLoadError( - `Failed to load tilemap: ${error.message}`, - metadata.guid, - TilemapAssetType, - error - ); - } - throw AssetLoadError.fileNotFound(metadata.guid, path); + async parse(content: IAssetContent, _context: IAssetParseContext): Promise { + if (!content.text) { + throw new Error('Tilemap content is empty'); } - } - /** - * Fetch with timeout - * 带超时的fetch - */ - private async fetchWithTimeout(url: string, timeout = 30000): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); + const jsonData = JSON.parse(content.text) as ITilemapAsset; - try { - const response = await fetch(url, { - signal: controller.signal, - mode: 'cors', - credentials: 'same-origin' - }); - return response; - } finally { - clearTimeout(timeoutId); + // 验证必要字段 + // Validate required fields + if (!jsonData.width || !jsonData.height || !jsonData.data) { + throw new Error('Invalid tilemap format: missing required fields'); } - } - /** - * Validate if the loader can handle this asset - * 验证加载器是否可以处理此资产 - */ - canLoad(path: string, _metadata: IAssetMetadata): boolean { - const lowerPath = path.toLowerCase(); - return this.supportedExtensions.some(ext => lowerPath.endsWith(ext)); + return jsonData; } /** diff --git a/packages/tilemap/src/loaders/TilesetLoader.ts b/packages/tilemap/src/loaders/TilesetLoader.ts index 55e92c01..8ee85301 100644 --- a/packages/tilemap/src/loaders/TilesetLoader.ts +++ b/packages/tilemap/src/loaders/TilesetLoader.ts @@ -4,11 +4,10 @@ */ import { - IAssetLoadOptions, - IAssetMetadata, - IAssetLoadResult, - AssetLoadError, - IAssetLoader + IAssetLoader, + IAssetContent, + IAssetParseContext, + AssetContentType } from '@esengine/asset-system'; import { TilesetAssetType } from '../constants'; @@ -56,89 +55,38 @@ export interface ITilesetAsset { export class TilesetLoader implements IAssetLoader { readonly supportedType = TilesetAssetType; readonly supportedExtensions = ['.tileset.json', '.tileset']; + readonly contentType: AssetContentType = 'text'; /** - * Load tileset asset - * 加载瓦片集资产 + * Parse tileset asset from text content + * 从文本内容解析瓦片集资产 */ - async load( - path: string, - metadata: IAssetMetadata, - options?: IAssetLoadOptions - ): Promise> { - const startTime = performance.now(); - - try { - const response = await this.fetchWithTimeout(path, options?.timeout); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const jsonData = await response.json() as ITilesetAsset; - - // 验证必要字段 - if (!jsonData.tileWidth || !jsonData.tileHeight || !jsonData.image) { - throw new Error('Invalid tileset format: missing required fields'); - } - - // 计算派生字段(如果未提供) - if (!jsonData.columns && jsonData.imageWidth) { - jsonData.columns = Math.floor(jsonData.imageWidth / jsonData.tileWidth); - } - if (!jsonData.rows && jsonData.imageHeight) { - jsonData.rows = Math.floor(jsonData.imageHeight / jsonData.tileHeight); - } - if (!jsonData.tileCount && jsonData.columns && jsonData.rows) { - jsonData.tileCount = jsonData.columns * jsonData.rows; - } - - return { - asset: jsonData, - handle: 0, - metadata, - loadTime: performance.now() - startTime - }; - } catch (error) { - if (error instanceof Error) { - throw new AssetLoadError( - `Failed to load tileset: ${error.message}`, - metadata.guid, - TilesetAssetType, - error - ); - } - throw AssetLoadError.fileNotFound(metadata.guid, path); + async parse(content: IAssetContent, _context: IAssetParseContext): Promise { + if (!content.text) { + throw new Error('Tileset content is empty'); } - } - /** - * Fetch with timeout - * 带超时的fetch - */ - private async fetchWithTimeout(url: string, timeout = 30000): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); + const jsonData = JSON.parse(content.text) as ITilesetAsset; - try { - const response = await fetch(url, { - signal: controller.signal, - mode: 'cors', - credentials: 'same-origin' - }); - return response; - } finally { - clearTimeout(timeoutId); + // 验证必要字段 + // Validate required fields + if (!jsonData.tileWidth || !jsonData.tileHeight || !jsonData.image) { + throw new Error('Invalid tileset format: missing required fields'); } - } - /** - * Validate if the loader can handle this asset - * 验证加载器是否可以处理此资产 - */ - canLoad(path: string, _metadata: IAssetMetadata): boolean { - const lowerPath = path.toLowerCase(); - return this.supportedExtensions.some(ext => lowerPath.endsWith(ext)); + // 计算派生字段(如果未提供) + // Calculate derived fields if not provided + if (!jsonData.columns && jsonData.imageWidth) { + jsonData.columns = Math.floor(jsonData.imageWidth / jsonData.tileWidth); + } + if (!jsonData.rows && jsonData.imageHeight) { + jsonData.rows = Math.floor(jsonData.imageHeight / jsonData.tileHeight); + } + if (!jsonData.tileCount && jsonData.columns && jsonData.rows) { + jsonData.tileCount = jsonData.columns * jsonData.rows; + } + + return jsonData; } /** diff --git a/packages/tilemap/src/systems/TilemapRenderingSystem.ts b/packages/tilemap/src/systems/TilemapRenderingSystem.ts index c2226c90..e0bbe608 100644 --- a/packages/tilemap/src/systems/TilemapRenderingSystem.ts +++ b/packages/tilemap/src/systems/TilemapRenderingSystem.ts @@ -1,20 +1,34 @@ import { EntitySystem, Matcher, ECSSystem, Entity } from '@esengine/ecs-framework'; import { TransformComponent } from '@esengine/engine-core'; +import { Color } from '@esengine/ecs-framework-math'; import type { IRenderDataProvider } from '@esengine/ecs-engine-bindgen'; -import { TilemapComponent } from '../TilemapComponent'; +import { TilemapComponent, type ITilemapLayerData } from '../TilemapComponent'; /** - * Tilemap render data for a single tilemap + * Tilemap render data for a single tilemap layer + * 单个瓦片地图图层的渲染数据 */ export interface TilemapRenderData { + /** Entity ID | 实体ID */ entityId: number; + /** Layer index within the tilemap | 图层在瓦片地图中的索引 */ + layerIndex: number; + /** Transform data [x, y, rotation, scaleX, scaleY, originX, originY] per tile | 每个瓦片的变换数据 */ transforms: Float32Array; + /** Texture IDs per tile | 每个瓦片的纹理ID */ textureIds: Uint32Array; + /** UV coordinates [u0, v0, u1, v1] per tile | 每个瓦片的UV坐标 */ uvs: Float32Array; + /** Packed colors (ARGB) per tile | 每个瓦片的打包颜色 */ colors: Uint32Array; + /** Number of tiles in this layer | 此图层的瓦片数量 */ tileCount: number; + /** Sorting order for rendering | 渲染排序顺序 */ sortingOrder: number; + /** Texture path for loading | 纹理路径 */ texturePath?: string; + /** Material ID for this layer (0 = default) | 此图层的材质ID(0 = 默认) */ + materialId: number; } /** @@ -28,22 +42,40 @@ export interface ViewportBounds { } /** - * 瓦片地图渲染系统 - 准备瓦片地图渲染数据 + * Cache key for layer render data + * 图层渲染数据的缓存键 + */ +type LayerCacheKey = `${number}_${number}`; + +/** + * 瓦片地图渲染系统 - 准备瓦片地图渲染数据(按图层) + * Tilemap rendering system - Prepares tilemap render data (per layer) */ @ECSSystem('TilemapRendering', { updateOrder: 40 }) export class TilemapRenderingSystem extends EntitySystem implements IRenderDataProvider { - private _renderDataCache: Map = new Map(); + /** Cache for layer render data: key = "entityId_layerIndex" | 图层渲染数据缓存 */ + private _layerRenderDataCache: Map = new Map(); + /** Current frame render data | 当前帧渲染数据 */ private _currentFrameData: TilemapRenderData[] = []; + /** Viewport bounds for culling | 视口边界用于剔除 */ private _viewportBounds: ViewportBounds | null = null; constructor() { super(Matcher.empty().all(TilemapComponent, TransformComponent)); } + /** + * Set viewport bounds for tile culling + * 设置视口边界用于瓦片剔除 + */ setViewportBounds(bounds: ViewportBounds): void { this._viewportBounds = bounds; } + /** + * Get render data for current frame + * 获取当前帧的渲染数据 + */ getRenderData(): readonly TilemapRenderData[] { return this._currentFrameData; } @@ -61,74 +93,95 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP if (!tilemap || !transform || !tilemap.visible) continue; - let renderData = this._renderDataCache.get(entity.id); + // Process each layer separately + // 分别处理每个图层 + const layers = tilemap.layers; + for (let layerIndex = 0; layerIndex < layers.length; layerIndex++) { + const layer = layers[layerIndex]; + if (!layer.visible) continue; - if (!renderData || tilemap.renderDirty) { - renderData = this.buildRenderData(entity.id, tilemap, transform); - this._renderDataCache.set(entity.id, renderData); - tilemap.renderDirty = false; - } else { - this.updateTransforms(renderData, tilemap, transform); + const cacheKey: LayerCacheKey = `${entity.id}_${layerIndex}`; + let renderData = this._layerRenderDataCache.get(cacheKey); + + if (!renderData || tilemap.renderDirty) { + renderData = this.buildLayerRenderData(entity.id, layerIndex, tilemap, transform, layer); + this._layerRenderDataCache.set(cacheKey, renderData); + } else { + this.updateLayerTransforms(renderData, layerIndex, tilemap, transform, layer); + } + + if (renderData.tileCount > 0) { + this._currentFrameData.push(renderData); + } } - this._currentFrameData.push(renderData); + // Clear dirty flag after processing all layers + // 处理完所有图层后清除脏标志 + tilemap.renderDirty = false; } + // Sort by sorting order (lower values render first) + // 按排序顺序排序(较小值先渲染) this._currentFrameData.sort((a, b) => a.sortingOrder - b.sortingOrder); } - private buildRenderData( + /** + * Build render data for a single layer + * 为单个图层构建渲染数据 + */ + private buildLayerRenderData( entityId: number, + layerIndex: number, tilemap: TilemapComponent, - transform: TransformComponent + transform: TransformComponent, + layer: ITilemapLayerData ): TilemapRenderData { - const mergedData = tilemap.getMergedTileData(); + const layerData = tilemap.getLayerData(layerIndex); + if (!layerData) { + return this.createEmptyRenderData(entityId, layerIndex, tilemap.sortingOrder, layer.materialId); + } + const width = tilemap.width; const height = tilemap.height; const tileWidth = tilemap.tileWidth; const tileHeight = tilemap.tileHeight; + // Calculate visible tile range // 计算可见瓦片范围 - let startCol = 0, - endCol = width; - let startRow = 0, - endRow = height; + const { startCol, endCol, startRow, endRow } = this.calculateVisibleRange( + width, height, tileWidth, tileHeight, transform + ); - if (this._viewportBounds) { - const bounds = this._viewportBounds; - const mapX = transform.position.x; - const mapY = transform.position.y; - - startCol = Math.max(0, Math.floor((bounds.left - mapX) / tileWidth)); - endCol = Math.min(width, Math.ceil((bounds.right - mapX) / tileWidth)); - startRow = Math.max(0, Math.floor((bounds.bottom - mapY) / tileHeight)); - endRow = Math.min(height, Math.ceil((bounds.top - mapY) / tileHeight)); - } - - // 计算非空瓦片数量 + // Count non-empty tiles in this layer + // 计算此图层的非空瓦片数量 let tileCount = 0; for (let row = startRow; row < endRow; row++) { for (let col = startCol; col < endCol; col++) { - if (mergedData[row * width + col] > 0) tileCount++; + if (layerData[row * width + col] > 0) tileCount++; } } + if (tileCount === 0) { + return this.createEmptyRenderData(entityId, layerIndex, tilemap.sortingOrder, layer.materialId); + } + const transforms = new Float32Array(tileCount * 7); const textureIds = new Uint32Array(tileCount); const uvs = new Float32Array(tileCount * 4); const colors = new Uint32Array(tileCount); - const colorValue = this.parseColor(tilemap.color, tilemap.alpha); + // Calculate color with layer opacity + // 计算带有图层透明度的颜色 + const effectiveAlpha = tilemap.alpha * layer.opacity; + const colorValue = Color.packHexAlpha(tilemap.color, effectiveAlpha); - // 计算旋转参数 // Calculate rotation parameters - // Note: transform.rotation.z is already in radians (set by Viewport gizmo) - // 注意:transform.rotation.z 已经是弧度(由 Viewport gizmo 设置) + // 计算旋转参数 const cos = Math.cos(transform.rotation.z); const sin = Math.sin(transform.rotation.z); - // Tilemap 旋转中心点(左下角为原点,中心在 width/2, height/2 处) - // Tilemap rotation pivot (origin at bottom-left, center at width/2, height/2) + // Tilemap rotation pivot + // Tilemap 旋转中心点 const pivotX = transform.position.x + (width * tileWidth * transform.scale.x) / 2; const pivotY = transform.position.y + (height * tileHeight * transform.scale.y) / 2; @@ -137,45 +190,43 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP for (let row = startRow; row < endRow; row++) { for (let col = startCol; col < endCol; col++) { - const gid = mergedData[row * width + col]; + const gid = layerData[row * width + col]; if (gid <= 0) continue; + // Find corresponding tileset // 查找对应的 tileset const tilesetInfo = tilemap.getTilesetForGid(gid); - if (!tilesetInfo) { - continue; - } + if (!tilesetInfo) continue; const { index: tilesetIndex, localId } = tilesetInfo; + // Get texture path // 获取纹理路径 if (!texturePath && tilemap.tilesets[tilesetIndex]) { texturePath = tilemap.tilesets[tilesetIndex].source; } - // 计算瓦片的本地位置(相对于 tilemap 中心) // Calculate tile local position (relative to tilemap center) + // 计算瓦片的本地位置(相对于 tilemap 中心) const localX = transform.position.x + col * tileWidth * transform.scale.x + (tileWidth * transform.scale.x) / 2 - pivotX; const localY = transform.position.y + (height - 1 - row) * tileHeight * transform.scale.y + (tileHeight * transform.scale.y) / 2 - pivotY; - // 应用旋转变换 // Apply rotation transform + // 应用旋转变换 const rotatedX = localX * cos - localY * sin + pivotX; const rotatedY = localX * sin + localY * cos + pivotY; // Transform: [x, y, rotation, scaleX, scaleY, originX, originY] - // Each tile rotates the same angle as the tilemap, so the whole map rotates as a unit - // 每个 tile 旋转与 tilemap 相同的角度,这样整个地图作为一个整体旋转 const tOffset = idx * 7; transforms[tOffset] = rotatedX; transforms[tOffset + 1] = rotatedY; - transforms[tOffset + 2] = transform.rotation.z; // Each tile rotates with tilemap + transforms[tOffset + 2] = transform.rotation.z; transforms[tOffset + 3] = tileWidth * transform.scale.x; transforms[tOffset + 4] = tileHeight * transform.scale.y; transforms[tOffset + 5] = 0.5; transforms[tOffset + 6] = 0.5; - // Texture ID (使用 tileset 的 textureId) + // Texture ID textureIds[idx] = tilemap.tilesets[tilesetIndex]?.textureId || 0; // UV coordinates @@ -200,33 +251,106 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP return { entityId, + layerIndex, transforms, textureIds, uvs, colors, tileCount, - sortingOrder: tilemap.sortingOrder, - texturePath + sortingOrder: tilemap.sortingOrder + layerIndex * 0.001, + texturePath, + materialId: layer.materialId ?? 0 }; } - private updateTransforms( + /** + * Update transforms for a layer (when only position/rotation/scale changed) + * 更新图层的变换(当只有位置/旋转/缩放改变时) + */ + private updateLayerTransforms( renderData: TilemapRenderData, + layerIndex: number, tilemap: TilemapComponent, - transform: TransformComponent + transform: TransformComponent, + layer: ITilemapLayerData ): void { - const mergedData = tilemap.getMergedTileData(); + const layerData = tilemap.getLayerData(layerIndex); + if (!layerData) return; + const width = tilemap.width; const height = tilemap.height; const tileWidth = tilemap.tileWidth; const tileHeight = tilemap.tileHeight; - // 计算可见瓦片范围(与 buildRenderData 保持一致) - // Calculate visible tile range (consistent with buildRenderData) - let startCol = 0, - endCol = width; - let startRow = 0, - endRow = height; + // Calculate visible tile range + // 计算可见瓦片范围 + const { startCol, endCol, startRow, endRow } = this.calculateVisibleRange( + width, height, tileWidth, tileHeight, transform + ); + + // Calculate rotation parameters + // 计算旋转参数 + const cos = Math.cos(transform.rotation.z); + const sin = Math.sin(transform.rotation.z); + + // Tilemap rotation pivot + // Tilemap 旋转中心点 + const pivotX = transform.position.x + (width * tileWidth * transform.scale.x) / 2; + const pivotY = transform.position.y + (height * tileHeight * transform.scale.y) / 2; + + let idx = 0; + for (let row = startRow; row < endRow; row++) { + for (let col = startCol; col < endCol; col++) { + if (layerData[row * width + col] <= 0) continue; + + // Calculate tile local position + // 计算瓦片本地位置 + const localX = transform.position.x + col * tileWidth * transform.scale.x + (tileWidth * transform.scale.x) / 2 - pivotX; + const localY = transform.position.y + (height - 1 - row) * tileHeight * transform.scale.y + (tileHeight * transform.scale.y) / 2 - pivotY; + + // Apply rotation transform + // 应用旋转变换 + const rotatedX = localX * cos - localY * sin + pivotX; + const rotatedY = localX * sin + localY * cos + pivotY; + + const tOffset = idx * 7; + renderData.transforms[tOffset] = rotatedX; + renderData.transforms[tOffset + 1] = rotatedY; + renderData.transforms[tOffset + 2] = transform.rotation.z; + renderData.transforms[tOffset + 3] = tileWidth * transform.scale.x; + renderData.transforms[tOffset + 4] = tileHeight * transform.scale.y; + + idx++; + } + } + + // Update color (alpha or layer opacity may have changed) + // 更新颜色(alpha 或图层透明度可能已更改) + const effectiveAlpha = tilemap.alpha * layer.opacity; + const colorValue = Color.packHexAlpha(tilemap.color, effectiveAlpha); + for (let i = 0; i < renderData.colors.length; i++) { + renderData.colors[i] = colorValue; + } + + // Update sorting order and material ID + // 更新排序顺序和材质ID + renderData.sortingOrder = tilemap.sortingOrder + layerIndex * 0.001; + renderData.materialId = layer.materialId ?? 0; + } + + /** + * Calculate visible tile range based on viewport bounds + * 根据视口边界计算可见瓦片范围 + */ + private calculateVisibleRange( + width: number, + height: number, + tileWidth: number, + tileHeight: number, + transform: TransformComponent + ): { startCol: number; endCol: number; startRow: number; endRow: number } { + let startCol = 0, endCol = width; + let startRow = 0, endRow = height; if (this._viewportBounds) { const bounds = this._viewportBounds; @@ -239,83 +363,52 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP endRow = Math.min(height, Math.ceil((bounds.top - mapY) / tileHeight)); } - // 计算旋转参数 - // Calculate rotation parameters - // Note: transform.rotation.z is already in radians (set by Viewport gizmo) - // 注意:transform.rotation.z 已经是弧度(由 Viewport gizmo 设置) - const cos = Math.cos(transform.rotation.z); - const sin = Math.sin(transform.rotation.z); - - // Tilemap 旋转中心点 - // Tilemap rotation pivot - const pivotX = transform.position.x + (width * tileWidth * transform.scale.x) / 2; - const pivotY = transform.position.y + (height * tileHeight * transform.scale.y) / 2; - - let idx = 0; - for (let row = startRow; row < endRow; row++) { - for (let col = startCol; col < endCol; col++) { - if (mergedData[row * width + col] <= 0) continue; - - // 计算瓦片的本地位置(相对于 tilemap 中心) - // Calculate tile local position (relative to tilemap center) - const localX = transform.position.x + col * tileWidth * transform.scale.x + (tileWidth * transform.scale.x) / 2 - pivotX; - const localY = transform.position.y + (height - 1 - row) * tileHeight * transform.scale.y + (tileHeight * transform.scale.y) / 2 - pivotY; - - // 应用旋转变换 - // Apply rotation transform - const rotatedX = localX * cos - localY * sin + pivotX; - const rotatedY = localX * sin + localY * cos + pivotY; - - // Each tile rotates the same angle as the tilemap - // 每个 tile 旋转与 tilemap 相同的角度 - const tOffset = idx * 7; - renderData.transforms[tOffset] = rotatedX; - renderData.transforms[tOffset + 1] = rotatedY; - renderData.transforms[tOffset + 2] = transform.rotation.z; - renderData.transforms[tOffset + 3] = tileWidth * transform.scale.x; - renderData.transforms[tOffset + 4] = tileHeight * transform.scale.y; - - idx++; - } - } - - // Update color (alpha may have changed) - // 更新颜色(alpha 可能已更改) - const colorValue = this.parseColor(tilemap.color, tilemap.alpha); - for (let i = 0; i < renderData.colors.length; i++) { - renderData.colors[i] = colorValue; - } - - renderData.sortingOrder = tilemap.sortingOrder; + return { startCol, endCol, startRow, endRow }; } - private parseColor(hex: string, alpha: number): number { - const colorHex = hex.replace('#', ''); - - let r = 255, - g = 255, - b = 255; - - if (colorHex.length === 6) { - r = parseInt(colorHex.substring(0, 2), 16); - g = parseInt(colorHex.substring(2, 4), 16); - b = parseInt(colorHex.substring(4, 6), 16); - } else if (colorHex.length === 3) { - r = parseInt(colorHex[0] + colorHex[0], 16); - g = parseInt(colorHex[1] + colorHex[1], 16); - b = parseInt(colorHex[2] + colorHex[2], 16); - } - - const a = Math.round(alpha * 255); - return (a << 24) | (b << 16) | (g << 8) | r; + /** + * Create empty render data + * 创建空的渲染数据 + */ + private createEmptyRenderData( + entityId: number, + layerIndex: number, + sortingOrder: number, + materialId?: number + ): TilemapRenderData { + return { + entityId, + layerIndex, + transforms: new Float32Array(0), + textureIds: new Uint32Array(0), + uvs: new Float32Array(0), + colors: new Uint32Array(0), + tileCount: 0, + sortingOrder: sortingOrder + layerIndex * 0.001, + materialId: materialId ?? 0 + }; } protected override onRemoved(entity: Entity): void { - this._renderDataCache.delete(entity.id); + // Remove all cached layer data for this entity + // 移除此实体的所有缓存图层数据 + const keysToDelete: LayerCacheKey[] = []; + for (const key of this._layerRenderDataCache.keys()) { + if (key.startsWith(`${entity.id}_`)) { + keysToDelete.push(key); + } + } + for (const key of keysToDelete) { + this._layerRenderDataCache.delete(key); + } } + /** + * Clear all cached render data + * 清除所有缓存的渲染数据 + */ clearCache(): void { - this._renderDataCache.clear(); + this._layerRenderDataCache.clear(); this._currentFrameData = []; } }