feat(tilemap): 增强tilemap编辑器和动画系统
This commit is contained in:
@@ -24,6 +24,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@esengine/tilemap": "workspace:*"
|
"@esengine/tilemap": "workspace:*"
|
||||||
},
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@esengine/editor-core": "workspace:*"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@esengine/ecs-framework": "workspace:*",
|
"@esengine/ecs-framework": "workspace:*",
|
||||||
"@esengine/engine-core": "workspace:*",
|
"@esengine/engine-core": "workspace:*",
|
||||||
|
|||||||
@@ -4,11 +4,14 @@
|
|||||||
|
|
||||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import type { TilemapComponent } from '@esengine/tilemap';
|
import type { TilemapComponent } from '@esengine/tilemap';
|
||||||
|
import { tilemapAnimationSystem } from '@esengine/tilemap';
|
||||||
import { useTilemapEditorStore } from '../stores/TilemapEditorStore';
|
import { useTilemapEditorStore } from '../stores/TilemapEditorStore';
|
||||||
import type { ITilemapTool, ToolContext } from '../tools/ITilemapTool';
|
import type { ITilemapTool, ToolContext } from '../tools/ITilemapTool';
|
||||||
import { BrushTool } from '../tools/BrushTool';
|
import { BrushTool } from '../tools/BrushTool';
|
||||||
import { EraserTool } from '../tools/EraserTool';
|
import { EraserTool } from '../tools/EraserTool';
|
||||||
import { FillTool } from '../tools/FillTool';
|
import { FillTool } from '../tools/FillTool';
|
||||||
|
import { RectangleTool } from '../tools/RectangleTool';
|
||||||
|
import { SelectTool } from '../tools/SelectTool';
|
||||||
|
|
||||||
interface TilemapCanvasProps {
|
interface TilemapCanvasProps {
|
||||||
tilemap: TilemapComponent;
|
tilemap: TilemapComponent;
|
||||||
@@ -20,6 +23,8 @@ const tools: Record<string, ITilemapTool> = {
|
|||||||
brush: new BrushTool(),
|
brush: new BrushTool(),
|
||||||
eraser: new EraserTool(),
|
eraser: new EraserTool(),
|
||||||
fill: new FillTool(),
|
fill: new FillTool(),
|
||||||
|
rectangle: new RectangleTool(),
|
||||||
|
select: new SelectTool(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
||||||
@@ -57,9 +62,12 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
|||||||
const layersKey = layers.map(l => `${l.visible}-${l.opacity}`).join(',');
|
const layersKey = layers.map(l => `${l.visible}-${l.opacity}`).join(',');
|
||||||
|
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
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 [mousePos, setMousePos] = useState<{ tileX: number; tileY: number } | null>(null);
|
||||||
const [spacePressed, setSpacePressed] = useState(false);
|
const [spacePressed, setSpacePressed] = useState(false);
|
||||||
|
const [animationTime, setAnimationTime] = useState(0);
|
||||||
|
const lastFrameTimeRef = useRef<number>(0);
|
||||||
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// Get canvas size
|
// Get canvas size
|
||||||
const canvasWidth = tilemap.width * tileWidth;
|
const canvasWidth = tilemap.width * tileWidth;
|
||||||
@@ -104,9 +112,16 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
|||||||
for (let x = 0; x < tilemap.width; x++) {
|
for (let x = 0; x < tilemap.width; x++) {
|
||||||
const tileIndex = tilemap.getTile(layerIndex, x, y);
|
const tileIndex = tilemap.getTile(layerIndex, x, y);
|
||||||
if (tileIndex > 0) {
|
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
|
// Calculate source position in tileset
|
||||||
const srcX = ((tileIndex - 1) % tilesetColumns) * tileWidth;
|
const srcX = (displayTileId % tilesetColumns) * tileWidth;
|
||||||
const srcY = Math.floor((tileIndex - 1) / tilesetColumns) * tileHeight;
|
const srcY = Math.floor(displayTileId / tilesetColumns) * tileHeight;
|
||||||
|
|
||||||
// Only draw if tile is within tileset bounds
|
// Only draw if tile is within tileset bounds
|
||||||
if (srcX + tileWidth <= tilesetImage.width && srcY + tileHeight <= tilesetImage.height) {
|
if (srcX + tileWidth <= tilesetImage.width && srcY + tileHeight <= tilesetImage.height) {
|
||||||
@@ -182,7 +197,7 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore();
|
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
|
// Update canvas size
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -223,6 +238,44 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
|||||||
draw();
|
draw();
|
||||||
}, [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
|
// Center view on first mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -288,7 +341,7 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
|||||||
// Middle mouse button, Alt+left click, or Space+left click for panning
|
// Middle mouse button, Alt+left click, or Space+left click for panning
|
||||||
if (e.button === 1 || (e.button === 0 && (e.altKey || spacePressed))) {
|
if (e.button === 1 || (e.button === 0 && (e.altKey || spacePressed))) {
|
||||||
setIsPanning(true);
|
setIsPanning(true);
|
||||||
setLastPanPos({ x: e.clientX, y: e.clientY });
|
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +366,7 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
|||||||
};
|
};
|
||||||
tool.onMouseDown(tileX, tileY, toolContext);
|
tool.onMouseDown(tileX, tileY, toolContext);
|
||||||
onTilemapChange?.();
|
onTilemapChange?.();
|
||||||
draw();
|
// draw() 由 useEffect 统一处理,避免重复绘制导致闪烁
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -326,10 +379,11 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
|||||||
|
|
||||||
// Handle panning
|
// Handle panning
|
||||||
if (isPanning) {
|
if (isPanning) {
|
||||||
const dx = e.clientX - lastPanPos.x;
|
const dx = e.clientX - lastPanPosRef.current.x;
|
||||||
const dy = e.clientY - lastPanPos.y;
|
const dy = e.clientY - lastPanPosRef.current.y;
|
||||||
setPan(panX + dx, panY + dy);
|
const state = useTilemapEditorStore.getState();
|
||||||
setLastPanPos({ x: e.clientX, y: e.clientY });
|
setPan(state.panX + dx, state.panY + dy);
|
||||||
|
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,8 +408,7 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
|||||||
onTilemapChange?.();
|
onTilemapChange?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// draw() 由 setMousePos 触发的 useEffect 统一处理
|
||||||
draw();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = (e: React.MouseEvent) => {
|
const handleMouseUp = (e: React.MouseEvent) => {
|
||||||
@@ -389,7 +442,7 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
|||||||
|
|
||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = () => {
|
||||||
setMousePos(null);
|
setMousePos(null);
|
||||||
draw();
|
// draw() 由 setMousePos 触发的 useEffect 统一处理
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWheel = (e: React.WheelEvent) => {
|
const handleWheel = (e: React.WheelEvent) => {
|
||||||
|
|||||||
388
packages/tilemap-editor/src/components/TilemapViewport.tsx
Normal file
388
packages/tilemap-editor/src/components/TilemapViewport.tsx
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
/**
|
||||||
|
* Tilemap Viewport - Engine-based rendering for tilemap editor
|
||||||
|
* Tilemap 视口 - 基于引擎的瓦片地图编辑器渲染
|
||||||
|
*
|
||||||
|
* Uses the same rendering pipeline as the main editor viewport.
|
||||||
|
* 使用与主编辑器视口相同的渲染管线。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Core } from '@esengine/ecs-framework';
|
||||||
|
import { IViewportService_ID, type IViewportService } from '@esengine/editor-core';
|
||||||
|
import type { TilemapComponent } from '@esengine/tilemap';
|
||||||
|
import { useTilemapEditorStore } from '../stores/TilemapEditorStore';
|
||||||
|
import type { ITilemapTool, ToolContext } from '../tools/ITilemapTool';
|
||||||
|
import { BrushTool } from '../tools/BrushTool';
|
||||||
|
import { EraserTool } from '../tools/EraserTool';
|
||||||
|
import { FillTool } from '../tools/FillTool';
|
||||||
|
|
||||||
|
interface TilemapViewportProps {
|
||||||
|
tilemap: TilemapComponent;
|
||||||
|
onTilemapChange?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEWPORT_ID = 'tilemap-editor-viewport';
|
||||||
|
const CANVAS_ID = 'tilemap-editor-canvas';
|
||||||
|
|
||||||
|
const tools: Record<string, ITilemapTool> = {
|
||||||
|
brush: new BrushTool(),
|
||||||
|
eraser: new EraserTool(),
|
||||||
|
fill: new FillTool(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TilemapViewport: React.FC<TilemapViewportProps> = ({
|
||||||
|
tilemap,
|
||||||
|
onTilemapChange,
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewportServiceRef = useRef<IViewportService | null>(null);
|
||||||
|
const registeredRef = useRef(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentTool,
|
||||||
|
zoom,
|
||||||
|
panX,
|
||||||
|
panY,
|
||||||
|
showGrid,
|
||||||
|
showCollision,
|
||||||
|
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>(IViewportService_ID);
|
||||||
|
viewportServiceRef.current = service ?? null;
|
||||||
|
|
||||||
|
if (!service) {
|
||||||
|
console.warn('[TilemapViewport] ViewportService not available');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Register viewport when canvas is ready
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const container = containerRef.current;
|
||||||
|
const service = viewportServiceRef.current;
|
||||||
|
|
||||||
|
if (!canvas || !container || !service) return;
|
||||||
|
|
||||||
|
// Wait for engine to be initialized
|
||||||
|
if (!service.isInitialized()) {
|
||||||
|
const checkInit = setInterval(() => {
|
||||||
|
if (service.isInitialized() && !registeredRef.current) {
|
||||||
|
clearInterval(checkInit);
|
||||||
|
setupViewport();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return () => clearInterval(checkInit);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupViewport();
|
||||||
|
|
||||||
|
function setupViewport() {
|
||||||
|
if (registeredRef.current || !canvas || !container || !service) return;
|
||||||
|
|
||||||
|
// Set canvas size
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
canvas.width = Math.floor(rect.width * dpr);
|
||||||
|
canvas.height = Math.floor(rect.height * dpr);
|
||||||
|
canvas.style.width = `${rect.width}px`;
|
||||||
|
canvas.style.height = `${rect.height}px`;
|
||||||
|
|
||||||
|
// Register viewport
|
||||||
|
service.registerViewport(VIEWPORT_ID, CANVAS_ID);
|
||||||
|
service.setViewportConfig(VIEWPORT_ID, showGrid, false); // No gizmos in tilemap editor
|
||||||
|
service.resizeViewport(VIEWPORT_ID, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
registeredRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (registeredRef.current && service) {
|
||||||
|
service.unregisterViewport(VIEWPORT_ID);
|
||||||
|
registeredRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [showGrid]);
|
||||||
|
|
||||||
|
// Handle resize
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const service = viewportServiceRef.current;
|
||||||
|
|
||||||
|
if (!container || !canvas || !service || !registeredRef.current) return;
|
||||||
|
|
||||||
|
let rafId: number | null = null;
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
}
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const newWidth = Math.floor(rect.width * dpr);
|
||||||
|
const newHeight = Math.floor(rect.height * dpr);
|
||||||
|
|
||||||
|
if (canvas.width !== newWidth || canvas.height !== newHeight) {
|
||||||
|
canvas.width = newWidth;
|
||||||
|
canvas.height = newHeight;
|
||||||
|
canvas.style.width = `${rect.width}px`;
|
||||||
|
canvas.style.height = `${rect.height}px`;
|
||||||
|
service.resizeViewport(VIEWPORT_ID, newWidth, newHeight);
|
||||||
|
}
|
||||||
|
rafId = null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
}
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update camera when pan/zoom changes
|
||||||
|
useEffect(() => {
|
||||||
|
const service = viewportServiceRef.current;
|
||||||
|
if (!service || !registeredRef.current) return;
|
||||||
|
|
||||||
|
// Convert pan to camera position
|
||||||
|
// In engine, camera position is the center of view
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const centerX = (canvas.width / 2 - panX) / zoom;
|
||||||
|
const centerY = (canvas.height / 2 - panY) / zoom;
|
||||||
|
|
||||||
|
service.setViewportCamera(VIEWPORT_ID, {
|
||||||
|
x: centerX,
|
||||||
|
y: -centerY, // Y is flipped
|
||||||
|
zoom: zoom
|
||||||
|
});
|
||||||
|
}, [panX, panY, zoom]);
|
||||||
|
|
||||||
|
// Update grid visibility
|
||||||
|
useEffect(() => {
|
||||||
|
const service = viewportServiceRef.current;
|
||||||
|
if (!service || !registeredRef.current) return;
|
||||||
|
|
||||||
|
service.setViewportConfig(VIEWPORT_ID, showGrid, false);
|
||||||
|
}, [showGrid]);
|
||||||
|
|
||||||
|
// Space key for panning mode
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === 'Space' && !e.repeat) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSpacePressed(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
setSpacePressed(false);
|
||||||
|
setIsPanning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
window.addEventListener('keyup', handleKeyUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
window.removeEventListener('keyup', handleKeyUp);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Convert screen coordinates to tile coordinates
|
||||||
|
const screenToTile = useCallback((screenX: number, screenY: number) => {
|
||||||
|
const x = (screenX - panX) / zoom;
|
||||||
|
const y = (screenY - panY) / zoom;
|
||||||
|
return {
|
||||||
|
tileX: Math.floor(x / tileWidth),
|
||||||
|
tileY: Math.floor(y / tileHeight),
|
||||||
|
};
|
||||||
|
}, [panX, panY, zoom, tileWidth, tileHeight]);
|
||||||
|
|
||||||
|
// Mouse handlers
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// Middle mouse button, Alt+left click, or Space+left click for panning
|
||||||
|
if (e.button === 1 || (e.button === 0 && (e.altKey || spacePressed))) {
|
||||||
|
setIsPanning(true);
|
||||||
|
setLastPanPos({ x: e.clientX, y: e.clientY });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save undo state
|
||||||
|
const layerData = tilemap.getLayerData(currentLayer);
|
||||||
|
if (layerData) {
|
||||||
|
pushUndo(layerData.slice());
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tileX, tileY } = screenToTile(x, y);
|
||||||
|
const tool = tools[currentTool];
|
||||||
|
if (tool) {
|
||||||
|
const toolContext: ToolContext = {
|
||||||
|
tilemap,
|
||||||
|
selectedTiles,
|
||||||
|
currentLayer,
|
||||||
|
layerLocked,
|
||||||
|
brushSize,
|
||||||
|
editingCollision,
|
||||||
|
tileWidth,
|
||||||
|
tileHeight,
|
||||||
|
};
|
||||||
|
tool.onMouseDown(tileX, tileY, toolContext);
|
||||||
|
onTilemapChange?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// Handle panning
|
||||||
|
if (isPanning) {
|
||||||
|
const dx = e.clientX - lastPanPos.x;
|
||||||
|
const dy = e.clientY - lastPanPos.y;
|
||||||
|
const state = useTilemapEditorStore.getState();
|
||||||
|
setPan(state.panX + dx, state.panY + dy);
|
||||||
|
setLastPanPos({ x: e.clientX, y: e.clientY });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tileX, tileY } = screenToTile(x, y);
|
||||||
|
setMousePos({ tileX, tileY });
|
||||||
|
|
||||||
|
// Handle tool drag
|
||||||
|
if (e.buttons === 1) {
|
||||||
|
const tool = tools[currentTool];
|
||||||
|
if (tool) {
|
||||||
|
const toolContext: ToolContext = {
|
||||||
|
tilemap,
|
||||||
|
selectedTiles,
|
||||||
|
currentLayer,
|
||||||
|
layerLocked,
|
||||||
|
brushSize,
|
||||||
|
editingCollision,
|
||||||
|
tileWidth,
|
||||||
|
tileHeight,
|
||||||
|
};
|
||||||
|
tool.onMouseMove(tileX, tileY, toolContext);
|
||||||
|
onTilemapChange?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = (e: React.MouseEvent) => {
|
||||||
|
if (isPanning) {
|
||||||
|
setIsPanning(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const { tileX, tileY } = screenToTile(x, y);
|
||||||
|
|
||||||
|
const tool = tools[currentTool];
|
||||||
|
if (tool) {
|
||||||
|
const toolContext: ToolContext = {
|
||||||
|
tilemap,
|
||||||
|
selectedTiles,
|
||||||
|
currentLayer,
|
||||||
|
layerLocked,
|
||||||
|
brushSize,
|
||||||
|
editingCollision,
|
||||||
|
tileWidth,
|
||||||
|
tileHeight,
|
||||||
|
};
|
||||||
|
tool.onMouseUp(tileX, tileY, toolContext);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setMousePos(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheel = (e: React.WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
const newZoom = Math.max(0.1, Math.min(10, zoom * delta));
|
||||||
|
|
||||||
|
// Zoom towards mouse position
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
if (rect) {
|
||||||
|
const mouseX = e.clientX - rect.left;
|
||||||
|
const mouseY = e.clientY - rect.top;
|
||||||
|
const newPanX = mouseX - (mouseX - panX) * (newZoom / zoom);
|
||||||
|
const newPanY = mouseY - (mouseY - panY) * (newZoom / zoom);
|
||||||
|
setPan(newPanX, newPanY);
|
||||||
|
}
|
||||||
|
|
||||||
|
setZoom(newZoom);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine cursor style
|
||||||
|
const getCursor = () => {
|
||||||
|
if (isPanning) return 'grabbing';
|
||||||
|
if (spacePressed) return 'grab';
|
||||||
|
return tools[currentTool]?.cursor || 'crosshair';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="tilemap-canvas-container">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
id={CANVAS_ID}
|
||||||
|
className="tilemap-canvas"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
style={{ cursor: getCursor() }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { useTilemapEditorStore, type TileSelection } from '../stores/TilemapEditorStore';
|
import { useTilemapEditorStore, type TileSelection } from '../stores/TilemapEditorStore';
|
||||||
|
import type { ITilesetData, ITileAnimation } from '@esengine/tilemap';
|
||||||
|
|
||||||
interface TilesetPreviewProps {
|
interface TilesetPreviewProps {
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
@@ -11,7 +12,10 @@ interface TilesetPreviewProps {
|
|||||||
tileHeight: number;
|
tileHeight: number;
|
||||||
columns: number;
|
columns: number;
|
||||||
rows: number;
|
rows: number;
|
||||||
|
tileset?: ITilesetData;
|
||||||
|
animatedTileIds?: Set<number>;
|
||||||
onSelectionChange?: (selection: TileSelection) => void;
|
onSelectionChange?: (selection: TileSelection) => void;
|
||||||
|
onEditAnimation?: (tileId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TilesetPreview: React.FC<TilesetPreviewProps> = ({
|
export const TilesetPreview: React.FC<TilesetPreviewProps> = ({
|
||||||
@@ -20,7 +24,10 @@ export const TilesetPreview: React.FC<TilesetPreviewProps> = ({
|
|||||||
tileHeight,
|
tileHeight,
|
||||||
columns,
|
columns,
|
||||||
rows,
|
rows,
|
||||||
|
tileset,
|
||||||
|
animatedTileIds,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
|
onEditAnimation,
|
||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -29,6 +36,7 @@ export const TilesetPreview: React.FC<TilesetPreviewProps> = ({
|
|||||||
const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
|
const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [selectionEnd, setSelectionEnd] = useState<{ x: number; y: number } | null>(null);
|
const [selectionEnd, setSelectionEnd] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tileId: number } | null>(null);
|
||||||
|
|
||||||
const selectedTiles = useTilemapEditorStore(state => state.selectedTiles);
|
const selectedTiles = useTilemapEditorStore(state => state.selectedTiles);
|
||||||
const setSelectedTiles = useTilemapEditorStore(state => state.setSelectedTiles);
|
const setSelectedTiles = useTilemapEditorStore(state => state.setSelectedTiles);
|
||||||
@@ -101,7 +109,24 @@ export const TilesetPreview: React.FC<TilesetPreviewProps> = ({
|
|||||||
selectedTiles.height * tileHeight - 2
|
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(() => {
|
useEffect(() => {
|
||||||
draw();
|
draw();
|
||||||
@@ -184,12 +209,47 @@ export const TilesetPreview: React.FC<TilesetPreviewProps> = ({
|
|||||||
setSelectionEnd(null);
|
setSelectionEnd(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!onEditAnimation) return;
|
||||||
|
|
||||||
|
const coords = getTileCoords(e);
|
||||||
|
const tileId = coords.y * columns + coords.x;
|
||||||
|
|
||||||
|
setContextMenu({
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
tileId
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseContextMenu = () => {
|
||||||
|
setContextMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditAnimation = () => {
|
||||||
|
if (contextMenu && onEditAnimation) {
|
||||||
|
onEditAnimation(contextMenu.tileId);
|
||||||
|
}
|
||||||
|
setContextMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close context menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = () => setContextMenu(null);
|
||||||
|
if (contextMenu) {
|
||||||
|
document.addEventListener('click', handleClick);
|
||||||
|
return () => document.removeEventListener('click', handleClick);
|
||||||
|
}
|
||||||
|
}, [contextMenu]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
>
|
>
|
||||||
@@ -204,7 +264,43 @@ export const TilesetPreview: React.FC<TilesetPreviewProps> = ({
|
|||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={handleMouseUp}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
/>
|
/>
|
||||||
|
{contextMenu && (
|
||||||
|
<div
|
||||||
|
className="tileset-context-menu"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: contextMenu.x,
|
||||||
|
top: contextMenu.y,
|
||||||
|
background: '#252526',
|
||||||
|
border: '1px solid #3c3c3c',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '4px 0',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
padding: '6px 16px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#e0e0e0',
|
||||||
|
fontSize: '12px',
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.background = '#094771'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.background = 'none'}
|
||||||
|
onClick={handleEditAnimation}
|
||||||
|
>
|
||||||
|
编辑动画... (瓦片 #{contextMenu.tileId})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
/**
|
||||||
|
* Tile Animation Editor Panel
|
||||||
|
* 瓦片动画编辑器面板
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { X, Play, Pause, Trash2, GripVertical } from 'lucide-react';
|
||||||
|
import type { ITileAnimation, ITileAnimationFrame, ITilesetData } from '@esengine/tilemap';
|
||||||
|
import '../../styles/TileAnimationEditor.css';
|
||||||
|
|
||||||
|
interface TileAnimationEditorProps {
|
||||||
|
tileId: number;
|
||||||
|
tileset: ITilesetData;
|
||||||
|
tilesetImage: HTMLImageElement | null;
|
||||||
|
animation: ITileAnimation | null;
|
||||||
|
onAnimationChange: (animation: ITileAnimation | null) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TileAnimationEditor: React.FC<TileAnimationEditorProps> = ({
|
||||||
|
tileId,
|
||||||
|
tileset,
|
||||||
|
tilesetImage,
|
||||||
|
animation,
|
||||||
|
onAnimationChange,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
const [frames, setFrames] = useState<ITileAnimationFrame[]>(
|
||||||
|
animation?.frames ?? []
|
||||||
|
);
|
||||||
|
const [defaultDuration, setDefaultDuration] = useState(100);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(true);
|
||||||
|
const [currentPreviewFrame, setCurrentPreviewFrame] = useState(0);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const tilesetCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const animationTimerRef = useRef<number | null>(null);
|
||||||
|
const lastFrameTimeRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const { tileWidth, tileHeight, columns } = tileset;
|
||||||
|
|
||||||
|
// Draw a single tile on canvas
|
||||||
|
const drawTile = useCallback((
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
tileIndex: number,
|
||||||
|
destX: number,
|
||||||
|
destY: number,
|
||||||
|
destWidth: number,
|
||||||
|
destHeight: number
|
||||||
|
) => {
|
||||||
|
if (!tilesetImage) return;
|
||||||
|
|
||||||
|
const srcX = (tileIndex % columns) * tileWidth;
|
||||||
|
const srcY = Math.floor(tileIndex / columns) * tileHeight;
|
||||||
|
|
||||||
|
ctx.drawImage(
|
||||||
|
tilesetImage,
|
||||||
|
srcX, srcY, tileWidth, tileHeight,
|
||||||
|
destX, destY, destWidth, destHeight
|
||||||
|
);
|
||||||
|
}, [tilesetImage, columns, tileWidth, tileHeight]);
|
||||||
|
|
||||||
|
// Animation preview loop
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || frames.length === 0) return;
|
||||||
|
|
||||||
|
const animate = (timestamp: number) => {
|
||||||
|
if (!lastFrameTimeRef.current) {
|
||||||
|
lastFrameTimeRef.current = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = timestamp - lastFrameTimeRef.current;
|
||||||
|
const currentFrame = frames[currentPreviewFrame];
|
||||||
|
|
||||||
|
if (currentFrame && elapsed >= currentFrame.duration) {
|
||||||
|
lastFrameTimeRef.current = timestamp;
|
||||||
|
setCurrentPreviewFrame((prev) => (prev + 1) % frames.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
animationTimerRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationTimerRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationTimerRef.current) {
|
||||||
|
cancelAnimationFrame(animationTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isPlaying, frames, currentPreviewFrame]);
|
||||||
|
|
||||||
|
// Draw preview canvas
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = previewCanvasRef.current;
|
||||||
|
if (!canvas || !tilesetImage) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = '#1a1a1a';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
if (frames.length > 0) {
|
||||||
|
const frame = frames[currentPreviewFrame];
|
||||||
|
if (frame) {
|
||||||
|
drawTile(ctx, frame.tileId, 16, 16, 64, 64);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
drawTile(ctx, tileId, 16, 16, 64, 64);
|
||||||
|
}
|
||||||
|
}, [frames, currentPreviewFrame, tileId, tilesetImage, drawTile]);
|
||||||
|
|
||||||
|
// Draw tileset selector canvas
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = tilesetCanvasRef.current;
|
||||||
|
if (!canvas || !tilesetImage) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
canvas.width = tilesetImage.width;
|
||||||
|
canvas.height = tilesetImage.height;
|
||||||
|
|
||||||
|
ctx.drawImage(tilesetImage, 0, 0);
|
||||||
|
|
||||||
|
// Highlight animated tiles
|
||||||
|
const animatedTileIds = new Set(frames.map(f => f.tileId));
|
||||||
|
ctx.strokeStyle = '#00ff00';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
for (const id of animatedTileIds) {
|
||||||
|
const x = (id % columns) * tileWidth;
|
||||||
|
const y = Math.floor(id / columns) * tileHeight;
|
||||||
|
ctx.strokeRect(x + 1, y + 1, tileWidth - 2, tileHeight - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight source tile
|
||||||
|
ctx.strokeStyle = '#ffff00';
|
||||||
|
const srcX = (tileId % columns) * tileWidth;
|
||||||
|
const srcY = Math.floor(tileId / columns) * tileHeight;
|
||||||
|
ctx.strokeRect(srcX + 1, srcY + 1, tileWidth - 2, tileHeight - 2);
|
||||||
|
}, [tilesetImage, frames, tileId, columns, tileWidth, tileHeight]);
|
||||||
|
|
||||||
|
// Handle tileset click to add frame
|
||||||
|
const handleTilesetClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = tilesetCanvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const scaleX = canvas.width / rect.width;
|
||||||
|
const scaleY = canvas.height / rect.height;
|
||||||
|
const x = (e.clientX - rect.left) * scaleX;
|
||||||
|
const y = (e.clientY - rect.top) * scaleY;
|
||||||
|
|
||||||
|
const col = Math.floor(x / tileWidth);
|
||||||
|
const row = Math.floor(y / tileHeight);
|
||||||
|
const clickedTileId = row * columns + col;
|
||||||
|
|
||||||
|
if (clickedTileId >= 0 && clickedTileId < tileset.tileCount) {
|
||||||
|
setFrames([...frames, { tileId: clickedTileId, duration: defaultDuration }]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle frame duration change
|
||||||
|
const handleDurationChange = (index: number, duration: number) => {
|
||||||
|
const newFrames = [...frames];
|
||||||
|
newFrames[index] = { ...newFrames[index], duration: Math.max(10, duration) };
|
||||||
|
setFrames(newFrames);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle frame delete
|
||||||
|
const handleDeleteFrame = (index: number) => {
|
||||||
|
setFrames(frames.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle frame reorder via drag
|
||||||
|
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||||
|
e.dataTransfer.setData('frameIndex', index.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOverIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const dragIndex = parseInt(e.dataTransfer.getData('frameIndex'), 10);
|
||||||
|
if (dragIndex === dropIndex) {
|
||||||
|
setDragOverIndex(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFrames = [...frames];
|
||||||
|
const [draggedFrame] = newFrames.splice(dragIndex, 1);
|
||||||
|
newFrames.splice(dropIndex, 0, draggedFrame);
|
||||||
|
setFrames(newFrames);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply changes
|
||||||
|
const handleApply = () => {
|
||||||
|
if (frames.length === 0) {
|
||||||
|
onAnimationChange(null);
|
||||||
|
} else {
|
||||||
|
onAnimationChange({ frames });
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear animation
|
||||||
|
const handleClear = () => {
|
||||||
|
setFrames([]);
|
||||||
|
setCurrentPreviewFrame(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tile-animation-editor-overlay">
|
||||||
|
<div className="tile-animation-editor">
|
||||||
|
<div className="animation-editor-header">
|
||||||
|
<h3>瓦片动画编辑器 - 瓦片 #{tileId}</h3>
|
||||||
|
<button className="close-btn" onClick={onClose}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="animation-editor-content">
|
||||||
|
{/* Preview section */}
|
||||||
|
<div className="animation-preview-section">
|
||||||
|
<div className="preview-box">
|
||||||
|
<canvas
|
||||||
|
ref={previewCanvasRef}
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
className="animation-preview-canvas"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="preview-controls">
|
||||||
|
<button
|
||||||
|
className={`preview-btn ${isPlaying ? 'active' : ''}`}
|
||||||
|
onClick={() => setIsPlaying(!isPlaying)}
|
||||||
|
title={isPlaying ? '暂停' : '播放'}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause size={16} /> : <Play size={16} />}
|
||||||
|
</button>
|
||||||
|
<span className="frame-indicator">
|
||||||
|
{frames.length > 0 ? `${currentPreviewFrame + 1}/${frames.length}` : '无帧'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame list */}
|
||||||
|
<div className="animation-frames-section">
|
||||||
|
<div className="frames-header">
|
||||||
|
<span>动画帧</span>
|
||||||
|
<span className="frame-count">{frames.length} 帧</span>
|
||||||
|
</div>
|
||||||
|
<div className="frames-list">
|
||||||
|
{frames.map((frame, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`frame-item ${dragOverIndex === index ? 'drag-over' : ''}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={(e) => handleDrop(e, index)}
|
||||||
|
>
|
||||||
|
<div className="frame-drag-handle">
|
||||||
|
<GripVertical size={14} />
|
||||||
|
</div>
|
||||||
|
<div className="frame-preview">
|
||||||
|
<canvas
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
ref={(canvas) => {
|
||||||
|
if (canvas && tilesetImage) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.clearRect(0, 0, 32, 32);
|
||||||
|
drawTile(ctx, frame.tileId, 0, 0, 32, 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="frame-info">
|
||||||
|
<span className="frame-tile-id">#{frame.tileId}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="frame-duration-input"
|
||||||
|
value={frame.duration}
|
||||||
|
onChange={(e) => handleDurationChange(index, parseInt(e.target.value, 10) || 100)}
|
||||||
|
min={10}
|
||||||
|
step={10}
|
||||||
|
/>
|
||||||
|
<span className="duration-unit">ms</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="frame-delete-btn"
|
||||||
|
onClick={() => handleDeleteFrame(index)}
|
||||||
|
title="删除帧"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{frames.length === 0 && (
|
||||||
|
<div className="frames-empty">
|
||||||
|
点击下方瓦片添加动画帧
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tileset selector */}
|
||||||
|
<div className="animation-tileset-section">
|
||||||
|
<div className="tileset-header">
|
||||||
|
<span>点击瓦片添加帧</span>
|
||||||
|
<div className="default-duration">
|
||||||
|
<label>默认时长:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={defaultDuration}
|
||||||
|
onChange={(e) => setDefaultDuration(parseInt(e.target.value, 10) || 100)}
|
||||||
|
min={10}
|
||||||
|
step={10}
|
||||||
|
/>
|
||||||
|
<span>ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="tileset-scroll-container">
|
||||||
|
<canvas
|
||||||
|
ref={tilesetCanvasRef}
|
||||||
|
className="animation-tileset-canvas"
|
||||||
|
onClick={handleTilesetClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer buttons */}
|
||||||
|
<div className="animation-editor-footer">
|
||||||
|
<button className="btn-secondary" onClick={handleClear}>
|
||||||
|
清除动画
|
||||||
|
</button>
|
||||||
|
<div className="footer-right">
|
||||||
|
<button className="btn-secondary" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary" onClick={handleApply}>
|
||||||
|
应用
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TileAnimationEditor;
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
* 瓦片集选择面板 - 左侧面板用于选择瓦片
|
* 瓦片集选择面板 - 左侧面板用于选择瓦片
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useMemo } from 'react';
|
||||||
import { Paintbrush, Eraser, PaintBucket, ChevronDown, Grid3x3, Search } from 'lucide-react';
|
import { Paintbrush, Eraser, PaintBucket, ChevronDown, Grid3x3, Search, Box, Square, BoxSelect } from 'lucide-react';
|
||||||
import { useTilemapEditorStore, type TilemapToolType } from '../../stores/TilemapEditorStore';
|
import { useTilemapEditorStore, type TilemapToolType } from '../../stores/TilemapEditorStore';
|
||||||
import { TilesetPreview } from '../TilesetPreview';
|
import { TilesetPreview } from '../TilesetPreview';
|
||||||
|
import { TileAnimationEditor } from './TileAnimationEditor';
|
||||||
|
import type { ITilesetData, ITileAnimation } from '@esengine/tilemap';
|
||||||
import '../../styles/TileSetSelectorPanel.css';
|
import '../../styles/TileSetSelectorPanel.css';
|
||||||
|
|
||||||
interface TilesetOption {
|
interface TilesetOption {
|
||||||
@@ -17,15 +19,21 @@ interface TilesetOption {
|
|||||||
interface TileSetSelectorPanelProps {
|
interface TileSetSelectorPanelProps {
|
||||||
tilesets: TilesetOption[];
|
tilesets: TilesetOption[];
|
||||||
activeTilesetIndex: number;
|
activeTilesetIndex: number;
|
||||||
|
activeTileset?: ITilesetData;
|
||||||
|
tilesetImage?: HTMLImageElement | null;
|
||||||
onTilesetChange: (index: number) => void;
|
onTilesetChange: (index: number) => void;
|
||||||
onAddTileset: () => void;
|
onAddTileset: () => void;
|
||||||
|
onTileAnimationChange?: (tileId: number, animation: ITileAnimation | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
||||||
tilesets,
|
tilesets,
|
||||||
activeTilesetIndex,
|
activeTilesetIndex,
|
||||||
|
activeTileset,
|
||||||
|
tilesetImage,
|
||||||
onTilesetChange,
|
onTilesetChange,
|
||||||
onAddTileset
|
onAddTileset,
|
||||||
|
onTileAnimationChange
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
currentTool,
|
currentTool,
|
||||||
@@ -35,26 +43,93 @@ export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
|||||||
tileHeight,
|
tileHeight,
|
||||||
tilesetColumns,
|
tilesetColumns,
|
||||||
tilesetRows,
|
tilesetRows,
|
||||||
selectedTiles
|
selectedTiles,
|
||||||
|
editingCollision,
|
||||||
|
setEditingCollision
|
||||||
} = useTilemapEditorStore();
|
} = useTilemapEditorStore();
|
||||||
|
|
||||||
const [showTilesetDropdown, setShowTilesetDropdown] = useState(false);
|
const [showTilesetDropdown, setShowTilesetDropdown] = useState(false);
|
||||||
const [previewZoom, setPreviewZoom] = useState(1);
|
const [previewZoom, setPreviewZoom] = useState(1);
|
||||||
|
const [editingAnimationTileId, setEditingAnimationTileId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Get animated tile IDs from tileset
|
||||||
|
const animatedTileIds = useMemo(() => {
|
||||||
|
const ids = new Set<number>();
|
||||||
|
if (activeTileset?.tiles) {
|
||||||
|
for (const tile of activeTileset.tiles) {
|
||||||
|
if (tile.animation && tile.animation.frames.length > 0) {
|
||||||
|
ids.add(tile.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, [activeTileset]);
|
||||||
|
|
||||||
|
// Get current animation for editing tile
|
||||||
|
const editingTileAnimation = useMemo(() => {
|
||||||
|
if (editingAnimationTileId === null || !activeTileset?.tiles) return null;
|
||||||
|
const tile = activeTileset.tiles.find(t => t.id === editingAnimationTileId);
|
||||||
|
return tile?.animation ?? null;
|
||||||
|
}, [editingAnimationTileId, activeTileset]);
|
||||||
|
|
||||||
|
const handleEditAnimation = useCallback((tileId: number) => {
|
||||||
|
setEditingAnimationTileId(tileId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAnimationChange = useCallback((animation: ITileAnimation | null) => {
|
||||||
|
if (editingAnimationTileId !== null && onTileAnimationChange) {
|
||||||
|
onTileAnimationChange(editingAnimationTileId, animation);
|
||||||
|
}
|
||||||
|
}, [editingAnimationTileId, onTileAnimationChange]);
|
||||||
|
|
||||||
|
const handleCloseAnimationEditor = useCallback(() => {
|
||||||
|
setEditingAnimationTileId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleToolChange = useCallback((tool: TilemapToolType) => {
|
const handleToolChange = useCallback((tool: TilemapToolType) => {
|
||||||
setCurrentTool(tool);
|
setCurrentTool(tool);
|
||||||
}, [setCurrentTool]);
|
}, [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 (
|
return (
|
||||||
<div className="tileset-selector-panel">
|
<div className="tileset-selector-panel">
|
||||||
|
{/* Mode toggle */}
|
||||||
|
<div className="tileset-mode-toggle">
|
||||||
|
<button
|
||||||
|
className={`mode-toggle-btn ${!editingCollision ? 'active' : ''}`}
|
||||||
|
onClick={() => handleToggleCollisionMode(false)}
|
||||||
|
title="瓦片编辑模式"
|
||||||
|
>
|
||||||
|
<Paintbrush size={14} />
|
||||||
|
<span>瓦片</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`mode-toggle-btn ${editingCollision ? 'active' : ''}`}
|
||||||
|
onClick={() => handleToggleCollisionMode(true)}
|
||||||
|
title="碰撞编辑模式"
|
||||||
|
>
|
||||||
|
<Box size={14} />
|
||||||
|
<span>碰撞</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tool buttons */}
|
{/* Tool buttons */}
|
||||||
<div className="tileset-tools">
|
<div className="tileset-tools">
|
||||||
<button
|
<button
|
||||||
className={`tileset-tool-btn ${currentTool === 'brush' ? 'active' : ''}`}
|
className={`tileset-tool-btn ${currentTool === 'brush' ? 'active' : ''}`}
|
||||||
onClick={() => handleToolChange('brush')}
|
onClick={() => handleToolChange('brush')}
|
||||||
title="绘制"
|
title={editingCollision ? "绘制碰撞" : "绘制瓦片"}
|
||||||
>
|
>
|
||||||
<Paintbrush size={24} />
|
<Paintbrush size={24} />
|
||||||
<span>绘制</span>
|
<span>绘制</span>
|
||||||
@@ -62,7 +137,7 @@ export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
|||||||
<button
|
<button
|
||||||
className={`tileset-tool-btn ${currentTool === 'eraser' ? 'active' : ''}`}
|
className={`tileset-tool-btn ${currentTool === 'eraser' ? 'active' : ''}`}
|
||||||
onClick={() => handleToolChange('eraser')}
|
onClick={() => handleToolChange('eraser')}
|
||||||
title="橡皮擦"
|
title={editingCollision ? "擦除碰撞" : "擦除瓦片"}
|
||||||
>
|
>
|
||||||
<Eraser size={24} />
|
<Eraser size={24} />
|
||||||
<span>橡皮擦</span>
|
<span>橡皮擦</span>
|
||||||
@@ -70,11 +145,27 @@ export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
|||||||
<button
|
<button
|
||||||
className={`tileset-tool-btn ${currentTool === 'fill' ? 'active' : ''}`}
|
className={`tileset-tool-btn ${currentTool === 'fill' ? 'active' : ''}`}
|
||||||
onClick={() => handleToolChange('fill')}
|
onClick={() => handleToolChange('fill')}
|
||||||
title="填充"
|
title={editingCollision ? "填充碰撞" : "填充瓦片"}
|
||||||
>
|
>
|
||||||
<PaintBucket size={24} />
|
<PaintBucket size={24} />
|
||||||
<span>填充</span>
|
<span>填充</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tileset-tool-btn ${currentTool === 'rectangle' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleToolChange('rectangle')}
|
||||||
|
title={editingCollision ? "矩形碰撞" : "矩形绘制"}
|
||||||
|
>
|
||||||
|
<Square size={24} />
|
||||||
|
<span>矩形</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tileset-tool-btn ${currentTool === 'select' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleToolChange('select')}
|
||||||
|
title="选择区域"
|
||||||
|
>
|
||||||
|
<BoxSelect size={24} />
|
||||||
|
<span>选择</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Tile Set selector */}
|
{/* Active Tile Set selector */}
|
||||||
@@ -95,7 +186,7 @@ export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
|||||||
className="tileset-dropdown-btn"
|
className="tileset-dropdown-btn"
|
||||||
onClick={() => setShowTilesetDropdown(!showTilesetDropdown)}
|
onClick={() => setShowTilesetDropdown(!showTilesetDropdown)}
|
||||||
>
|
>
|
||||||
<span>{activeTileset?.name || '(无)'}</span>
|
<span>{activeTilesetOption?.name || '(无)'}</span>
|
||||||
<ChevronDown size={14} />
|
<ChevronDown size={14} />
|
||||||
</button>
|
</button>
|
||||||
{showTilesetDropdown && (
|
{showTilesetDropdown && (
|
||||||
@@ -144,13 +235,23 @@ export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
|||||||
|
|
||||||
{/* Tile preview area */}
|
{/* Tile preview area */}
|
||||||
<div className="tileset-preview-area">
|
<div className="tileset-preview-area">
|
||||||
{tilesetImageUrl ? (
|
{editingCollision ? (
|
||||||
|
<div className="collision-mode-hint">
|
||||||
|
<Box size={32} />
|
||||||
|
<span className="collision-mode-title">碰撞编辑模式</span>
|
||||||
|
<span className="collision-mode-desc">使用画笔绘制碰撞区域</span>
|
||||||
|
<span className="collision-mode-desc">使用橡皮擦清除碰撞</span>
|
||||||
|
</div>
|
||||||
|
) : tilesetImageUrl ? (
|
||||||
<TilesetPreview
|
<TilesetPreview
|
||||||
imageUrl={tilesetImageUrl}
|
imageUrl={tilesetImageUrl}
|
||||||
tileWidth={tileWidth}
|
tileWidth={tileWidth}
|
||||||
tileHeight={tileHeight}
|
tileHeight={tileHeight}
|
||||||
columns={tilesetColumns}
|
columns={tilesetColumns}
|
||||||
rows={tilesetRows}
|
rows={tilesetRows}
|
||||||
|
tileset={activeTileset}
|
||||||
|
animatedTileIds={animatedTileIds}
|
||||||
|
onEditAnimation={handleEditAnimation}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="tileset-empty-hint">
|
<div className="tileset-empty-hint">
|
||||||
@@ -167,6 +268,18 @@ export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
|||||||
已选择: {selectedTiles.width}×{selectedTiles.height}
|
已选择: {selectedTiles.width}×{selectedTiles.height}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Animation Editor */}
|
||||||
|
{editingAnimationTileId !== null && activeTileset && tilesetImage && (
|
||||||
|
<TileAnimationEditor
|
||||||
|
tileId={editingAnimationTileId}
|
||||||
|
tileset={activeTileset}
|
||||||
|
tilesetImage={tilesetImage}
|
||||||
|
animation={editingTileAnimation}
|
||||||
|
onAnimationChange={handleAnimationChange}
|
||||||
|
onClose={handleCloseAnimationEditor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff
|
EyeOff,
|
||||||
|
FileBox
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTilemapEditorStore, type LayerState } from '../../stores/TilemapEditorStore';
|
import { useTilemapEditorStore, type LayerState } from '../../stores/TilemapEditorStore';
|
||||||
import type { TilemapComponent } from '@esengine/tilemap';
|
import type { TilemapComponent } from '@esengine/tilemap';
|
||||||
@@ -26,8 +27,11 @@ interface TilemapDetailsPanelProps {
|
|||||||
onAddLayer: () => void;
|
onAddLayer: () => void;
|
||||||
onRemoveLayer: (index: number) => void;
|
onRemoveLayer: (index: number) => void;
|
||||||
onMoveLayer: (from: number, to: number) => void;
|
onMoveLayer: (from: number, to: number) => void;
|
||||||
|
onDuplicateLayer: (index: number) => void;
|
||||||
onTilemapChange: () => void;
|
onTilemapChange: () => void;
|
||||||
onOpenAssetPicker: () => void;
|
onOpenAssetPicker: () => void;
|
||||||
|
/** Callback to open material picker for a specific layer */
|
||||||
|
onSelectLayerMaterial?: (layerIndex: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapsible section component
|
// Collapsible section component
|
||||||
@@ -123,6 +127,40 @@ const NumberProperty: React.FC<NumberPropertyProps> = ({
|
|||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Slider property for opacity etc.
|
||||||
|
interface SliderPropertyProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SliderProperty: React.FC<SliderPropertyProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min = 0,
|
||||||
|
max = 1,
|
||||||
|
step = 0.01
|
||||||
|
}) => (
|
||||||
|
<PropertyRow label={label}>
|
||||||
|
<div className="slider-wrapper">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="property-slider"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
/>
|
||||||
|
<span className="slider-value">{Math.round(value * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
</PropertyRow>
|
||||||
|
);
|
||||||
|
|
||||||
// Color property - unified style matching PropertyInspector
|
// Color property - unified style matching PropertyInspector
|
||||||
interface ColorPropertyProps {
|
interface ColorPropertyProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -175,13 +213,85 @@ const ColorProperty: React.FC<ColorPropertyProps> = ({ label, value, onChange })
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Material field - AssetField-like style for material selection
|
||||||
|
interface MaterialFieldProps {
|
||||||
|
label: string;
|
||||||
|
value: string | undefined;
|
||||||
|
onSelect: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MaterialField: React.FC<MaterialFieldProps> = ({ label, value, onSelect, onClear }) => {
|
||||||
|
const getFileName = (path: string) => {
|
||||||
|
const parts = path.split(/[\\/]/);
|
||||||
|
return parts[parts.length - 1].replace('.mat', '').replace('.json', '');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="material-field">
|
||||||
|
<label className="material-field__label">{label}</label>
|
||||||
|
<div className="material-field__content">
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="material-field__thumbnail">
|
||||||
|
<FileBox size={18} className="material-field__thumbnail-icon" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side */}
|
||||||
|
<div className="material-field__right">
|
||||||
|
{/* Dropdown */}
|
||||||
|
<div
|
||||||
|
className={`material-field__dropdown ${value ? 'has-value' : ''}`}
|
||||||
|
onClick={onSelect}
|
||||||
|
title={value || '点击选择材质'}
|
||||||
|
>
|
||||||
|
<span className="material-field__value">
|
||||||
|
{value ? getFileName(value) : '默认材质'}
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={12} className="material-field__dropdown-arrow" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="material-field__actions">
|
||||||
|
{value && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="material-field__btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigator.clipboard.writeText(value);
|
||||||
|
}}
|
||||||
|
title="复制路径"
|
||||||
|
>
|
||||||
|
<Copy size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="material-field__btn material-field__btn--clear"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClear();
|
||||||
|
}}
|
||||||
|
title="清除"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
||||||
tilemap,
|
tilemap,
|
||||||
onAddLayer,
|
onAddLayer,
|
||||||
onRemoveLayer,
|
onRemoveLayer,
|
||||||
onMoveLayer,
|
onMoveLayer,
|
||||||
|
onDuplicateLayer,
|
||||||
onTilemapChange,
|
onTilemapChange,
|
||||||
onOpenAssetPicker
|
onOpenAssetPicker,
|
||||||
|
onSelectLayerMaterial
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
layers,
|
layers,
|
||||||
@@ -189,19 +299,24 @@ export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
|||||||
setCurrentLayer,
|
setCurrentLayer,
|
||||||
toggleLayerVisibility,
|
toggleLayerVisibility,
|
||||||
setLayerOpacity,
|
setLayerOpacity,
|
||||||
|
setLayerColor,
|
||||||
|
setLayerHiddenInGame,
|
||||||
|
renameLayer,
|
||||||
showCollision,
|
showCollision,
|
||||||
setShowCollision
|
setShowCollision
|
||||||
} = useTilemapEditorStore();
|
} = 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 selectedLayer = layers[currentLayer];
|
||||||
const [hiddenInGame, setHiddenInGame] = useState(false);
|
|
||||||
const [layerCollides, setLayerCollides] = useState(true);
|
const [layerCollides, setLayerCollides] = useState(true);
|
||||||
const [overrideCollisionThickness, setOverrideCollisionThickness] = useState(false);
|
const [overrideCollisionThickness, setOverrideCollisionThickness] = useState(false);
|
||||||
const [overrideCollisionOffset, setOverrideCollisionOffset] = useState(false);
|
const [overrideCollisionOffset, setOverrideCollisionOffset] = useState(false);
|
||||||
const [collisionThickness, setCollisionThickness] = useState(50.0);
|
const [collisionThickness, setCollisionThickness] = useState(50.0);
|
||||||
const [collisionOffset, setCollisionOffset] = useState(0.0);
|
const [collisionOffset, setCollisionOffset] = useState(0.0);
|
||||||
const [layerColor, setLayerColor] = useState('#ffffff');
|
|
||||||
|
|
||||||
// hiddenInEditor is derived from layer visibility (inverse relationship)
|
// hiddenInEditor is derived from layer visibility (inverse relationship)
|
||||||
const hiddenInEditor = selectedLayer ? !selectedLayer.visible : false;
|
const hiddenInEditor = selectedLayer ? !selectedLayer.visible : false;
|
||||||
@@ -230,7 +345,71 @@ export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
|||||||
}
|
}
|
||||||
}, [toggleLayerVisibility, tilemap, onTilemapChange]);
|
}, [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 [tileGridColor, setTileGridColor] = useState('#333333');
|
||||||
const [multiTileGridColor, setMultiTileGridColor] = useState('#ff0000');
|
const [multiTileGridColor, setMultiTileGridColor] = useState('#ff0000');
|
||||||
const [layerGridColor, setLayerGridColor] = useState('#00ff00');
|
const [layerGridColor, setLayerGridColor] = useState('#00ff00');
|
||||||
@@ -348,7 +527,8 @@ export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="layer-action-btn"
|
className="layer-action-btn"
|
||||||
title="复制"
|
onClick={() => onDuplicateLayer(currentLayer)}
|
||||||
|
title="复制图层"
|
||||||
>
|
>
|
||||||
<Copy size={14} />
|
<Copy size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -365,8 +545,26 @@ export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
|||||||
|
|
||||||
{/* Selected Layer Section */}
|
{/* Selected Layer Section */}
|
||||||
<Section title="选定层">
|
<Section title="选定层">
|
||||||
<PropertyRow label="">
|
<PropertyRow label="名称">
|
||||||
<span className="selected-layer-name">{selectedLayer?.name || '图层 1'}</span>
|
{isEditingName ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="layer-name-input"
|
||||||
|
value={editingName}
|
||||||
|
onChange={(e) => setEditingName(e.target.value)}
|
||||||
|
onBlur={handleFinishEditName}
|
||||||
|
onKeyDown={handleNameKeyDown}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="selected-layer-name editable"
|
||||||
|
onDoubleClick={handleStartEditName}
|
||||||
|
title="双击编辑名称"
|
||||||
|
>
|
||||||
|
{selectedLayer?.name || '图层 1'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
<ToggleProperty
|
<ToggleProperty
|
||||||
label="编辑器中隐藏"
|
label="编辑器中隐藏"
|
||||||
@@ -375,8 +573,16 @@ export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
|||||||
/>
|
/>
|
||||||
<ToggleProperty
|
<ToggleProperty
|
||||||
label="游戏中隐藏"
|
label="游戏中隐藏"
|
||||||
checked={hiddenInGame}
|
checked={selectedLayer?.hiddenInGame ?? false}
|
||||||
onChange={setHiddenInGame}
|
onChange={handleHiddenInGameChange}
|
||||||
|
/>
|
||||||
|
<SliderProperty
|
||||||
|
label="图层透明度"
|
||||||
|
value={selectedLayer?.opacity ?? 1}
|
||||||
|
onChange={handleLayerOpacityChange}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
/>
|
/>
|
||||||
<ToggleProperty
|
<ToggleProperty
|
||||||
label="图层碰撞"
|
label="图层碰撞"
|
||||||
@@ -411,8 +617,8 @@ export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
<ColorProperty
|
<ColorProperty
|
||||||
label="图层颜色"
|
label="图层颜色"
|
||||||
value={layerColor}
|
value={selectedLayer?.color ?? '#ffffff'}
|
||||||
onChange={setLayerColor}
|
onChange={handleLayerColorChange}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@@ -457,13 +663,18 @@ export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Material Section */}
|
{/* Material Section */}
|
||||||
<Section title="材质" defaultOpen={false}>
|
<Section title="图层材质" defaultOpen={true}>
|
||||||
<PropertyRow label="材质">
|
<div className="material-section-content">
|
||||||
<button className="asset-dropdown">
|
<MaterialField
|
||||||
<span>Masked</span>
|
label={`${selectedLayer?.name || '图层'} 材质`}
|
||||||
<ChevronDown size={12} />
|
value={tilemap.getLayerMaterial(currentLayer)}
|
||||||
</button>
|
onSelect={() => onSelectLayerMaterial?.(currentLayer)}
|
||||||
</PropertyRow>
|
onClear={() => {
|
||||||
|
tilemap.setLayerMaterial(currentLayer, '');
|
||||||
|
onTilemapChange();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Advanced Section */}
|
{/* Advanced Section */}
|
||||||
|
|||||||
@@ -11,28 +11,17 @@ import {
|
|||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Map,
|
|
||||||
Save,
|
Save,
|
||||||
Scaling,
|
|
||||||
X,
|
X,
|
||||||
Search,
|
Search,
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
File,
|
File,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
MousePointer2,
|
|
||||||
Move,
|
|
||||||
RotateCw,
|
|
||||||
Maximize2,
|
|
||||||
Minimize2,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Magnet,
|
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
SunDim,
|
|
||||||
Layers,
|
|
||||||
Box,
|
Box,
|
||||||
View,
|
Map
|
||||||
Sidebar
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Core, Entity } from '@esengine/ecs-framework';
|
import { Core, Entity } from '@esengine/ecs-framework';
|
||||||
import { MessageHub, ProjectService, IFileSystemService, type IFileSystem, IDialogService, type IDialog } from '@esengine/editor-core';
|
import { MessageHub, ProjectService, IFileSystemService, type IFileSystem, IDialogService, type IDialog } from '@esengine/editor-core';
|
||||||
@@ -596,12 +585,9 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
const [showAssetPicker, setShowAssetPicker] = useState(false);
|
const [showAssetPicker, setShowAssetPicker] = useState(false);
|
||||||
const [showResizeDialog, setShowResizeDialog] = useState(false);
|
const [showResizeDialog, setShowResizeDialog] = useState(false);
|
||||||
const [activeTilesetIndex, setActiveTilesetIndex] = useState(0);
|
const [activeTilesetIndex, setActiveTilesetIndex] = useState(0);
|
||||||
|
// Material picker state
|
||||||
// Viewport state
|
const [showMaterialPicker, setShowMaterialPicker] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState<'right' | 'left' | 'top' | 'bottom'>('right');
|
const [materialPickerLayerIndex, setMaterialPickerLayerIndex] = useState(0);
|
||||||
const [litMode, setLitMode] = useState(true);
|
|
||||||
const [showViewOptions, setShowViewOptions] = useState(false);
|
|
||||||
const [transformMode, setTransformMode] = useState<'select' | 'move' | 'rotate' | 'scale'>('select');
|
|
||||||
|
|
||||||
const messageHub = propMessageHub || Core.services.resolve(MessageHub);
|
const messageHub = propMessageHub || Core.services.resolve(MessageHub);
|
||||||
|
|
||||||
@@ -630,7 +616,10 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
setPan,
|
setPan,
|
||||||
setTileset,
|
setTileset,
|
||||||
setLayers,
|
setLayers,
|
||||||
setCurrentLayer
|
setCurrentLayer,
|
||||||
|
currentLayer,
|
||||||
|
undo,
|
||||||
|
redo
|
||||||
} = useTilemapEditorStore();
|
} = useTilemapEditorStore();
|
||||||
|
|
||||||
// Load tileset from component (defined early for use in effects)
|
// Load tileset from component (defined early for use in effects)
|
||||||
@@ -721,7 +710,9 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
name: layer.name,
|
name: layer.name,
|
||||||
visible: layer.visible,
|
visible: layer.visible,
|
||||||
locked: false,
|
locked: false,
|
||||||
opacity: layer.opacity
|
opacity: layer.opacity,
|
||||||
|
color: layer.color ?? '#ffffff',
|
||||||
|
hiddenInGame: layer.hiddenInGame ?? false
|
||||||
}));
|
}));
|
||||||
setLayers(layerStates);
|
setLayers(layerStates);
|
||||||
setCurrentLayer(0);
|
setCurrentLayer(0);
|
||||||
@@ -788,7 +779,9 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
name: layer.name,
|
name: layer.name,
|
||||||
visible: layer.visible,
|
visible: layer.visible,
|
||||||
locked: false,
|
locked: false,
|
||||||
opacity: layer.opacity
|
opacity: layer.opacity,
|
||||||
|
color: layer.color ?? '#ffffff',
|
||||||
|
hiddenInGame: layer.hiddenInGame ?? false
|
||||||
}));
|
}));
|
||||||
setLayers(layerStates);
|
setLayers(layerStates);
|
||||||
setCurrentLayer(0);
|
setCurrentLayer(0);
|
||||||
@@ -800,7 +793,6 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
|
|
||||||
const unsubscribeModified = messageHub.subscribe('scene:modified', () => {
|
const unsubscribeModified = messageHub.subscribe('scene:modified', () => {
|
||||||
loadTilesetFromComponent(tilemap);
|
loadTilesetFromComponent(tilemap);
|
||||||
setTilemapKey(`${tilemap.width}-${tilemap.height}-${Date.now()}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubscribeRestored = messageHub.subscribe('scene:restored', () => {
|
const unsubscribeRestored = messageHub.subscribe('scene:restored', () => {
|
||||||
@@ -840,16 +832,61 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
messageHub?.publish('scene:modified', {});
|
messageHub?.publish('scene:modified', {});
|
||||||
}, [messageHub]);
|
}, [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 () => {
|
const handleSaveTilemap = useCallback(async () => {
|
||||||
if (!tilemap || !entity) return;
|
if (!tilemap) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tilemapData = tilemap.exportToData();
|
const tilemapData = tilemap.exportToData();
|
||||||
const jsonContent = JSON.stringify(tilemapData, null, 2);
|
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) {
|
if (!tilemapAssetPath) {
|
||||||
console.warn('Tilemap asset path not set');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,21 +922,65 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
duration: 3000
|
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
|
// Keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
// Check if Ctrl or Cmd is pressed
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
switch (e.key.toLowerCase()) {
|
||||||
|
case 's':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleSaveTilemap();
|
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 });
|
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||||
return () => window.removeEventListener('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 handleZoomIn = () => setZoom(Math.min(10, zoom * 1.2));
|
||||||
const handleZoomOut = () => setZoom(Math.max(0.1, zoom / 1.2));
|
const handleZoomOut = () => setZoom(Math.max(0.1, zoom / 1.2));
|
||||||
@@ -922,7 +1003,9 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
name: layer.name,
|
name: layer.name,
|
||||||
visible: layer.visible,
|
visible: layer.visible,
|
||||||
locked: false,
|
locked: false,
|
||||||
opacity: layer.opacity
|
opacity: layer.opacity,
|
||||||
|
color: layer.color ?? '#ffffff',
|
||||||
|
hiddenInGame: layer.hiddenInGame ?? false
|
||||||
}));
|
}));
|
||||||
setLayers(layerStates);
|
setLayers(layerStates);
|
||||||
setCurrentLayer(tilemap.layers.length - 1);
|
setCurrentLayer(tilemap.layers.length - 1);
|
||||||
@@ -938,7 +1021,9 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
name: layer.name,
|
name: layer.name,
|
||||||
visible: layer.visible,
|
visible: layer.visible,
|
||||||
locked: false,
|
locked: false,
|
||||||
opacity: layer.opacity
|
opacity: layer.opacity,
|
||||||
|
color: layer.color ?? '#ffffff',
|
||||||
|
hiddenInGame: layer.hiddenInGame ?? false
|
||||||
}));
|
}));
|
||||||
setLayers(layerStates);
|
setLayers(layerStates);
|
||||||
const { currentLayer } = useTilemapEditorStore.getState();
|
const { currentLayer } = useTilemapEditorStore.getState();
|
||||||
@@ -958,7 +1043,9 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
name: layer.name,
|
name: layer.name,
|
||||||
visible: layer.visible,
|
visible: layer.visible,
|
||||||
locked: false,
|
locked: false,
|
||||||
opacity: layer.opacity
|
opacity: layer.opacity,
|
||||||
|
color: layer.color ?? '#ffffff',
|
||||||
|
hiddenInGame: layer.hiddenInGame ?? false
|
||||||
}));
|
}));
|
||||||
setLayers(layerStates);
|
setLayers(layerStates);
|
||||||
setCurrentLayer(toIndex);
|
setCurrentLayer(toIndex);
|
||||||
@@ -966,6 +1053,26 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
handleTilemapChange();
|
handleTilemapChange();
|
||||||
}, [tilemap, setLayers, setCurrentLayer, 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
|
// Tileset operations
|
||||||
const handleAddTileset = useCallback(() => {
|
const handleAddTileset = useCallback(() => {
|
||||||
if (!tilemap) return;
|
if (!tilemap) return;
|
||||||
@@ -995,6 +1102,18 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
handleTilemapChange();
|
handleTilemapChange();
|
||||||
}, [tilemap, 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
|
// Get tileset list
|
||||||
const tilesetOptions = tilemap?.tilesets.map((t, i) => ({
|
const tilesetOptions = tilemap?.tilesets.map((t, i) => ({
|
||||||
name: t.data?.name || `Tileset ${i + 1}`,
|
name: t.data?.name || `Tileset ${i + 1}`,
|
||||||
@@ -1025,8 +1144,11 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
<TileSetSelectorPanel
|
<TileSetSelectorPanel
|
||||||
tilesets={tilesetOptions}
|
tilesets={tilesetOptions}
|
||||||
activeTilesetIndex={activeTilesetIndex}
|
activeTilesetIndex={activeTilesetIndex}
|
||||||
|
activeTileset={activeTilesetData}
|
||||||
|
tilesetImage={tilesetImage}
|
||||||
onTilesetChange={handleTilesetChange}
|
onTilesetChange={handleTilesetChange}
|
||||||
onAddTileset={handleAddTileset}
|
onAddTileset={handleAddTileset}
|
||||||
|
onTileAnimationChange={handleTileAnimationChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1038,69 +1160,6 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
{/* Viewport top toolbar */}
|
{/* Viewport top toolbar */}
|
||||||
<div className="viewport-toolbar">
|
<div className="viewport-toolbar">
|
||||||
<div className="viewport-toolbar-left">
|
<div className="viewport-toolbar-left">
|
||||||
{/* View mode buttons */}
|
|
||||||
<div className="viewport-btn-group">
|
|
||||||
<button
|
|
||||||
className={`viewport-btn icon ${viewMode === 'right' ? 'active' : ''}`}
|
|
||||||
onClick={() => setViewMode('right')}
|
|
||||||
title="右视图"
|
|
||||||
>
|
|
||||||
<Box size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`viewport-btn icon ${litMode ? 'active' : ''}`}
|
|
||||||
onClick={() => setLitMode(!litMode)}
|
|
||||||
title="光照模式"
|
|
||||||
>
|
|
||||||
<SunDim size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="viewport-btn icon"
|
|
||||||
onClick={() => setShowViewOptions(!showViewOptions)}
|
|
||||||
title="显示选项"
|
|
||||||
>
|
|
||||||
<Layers size={14} />
|
|
||||||
<ChevronDown size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="viewport-toolbar-center">
|
|
||||||
{/* Transform tools */}
|
|
||||||
<div className="viewport-btn-group">
|
|
||||||
<button
|
|
||||||
className={`viewport-btn icon ${transformMode === 'select' ? 'active' : ''}`}
|
|
||||||
onClick={() => setTransformMode('select')}
|
|
||||||
title="选择"
|
|
||||||
>
|
|
||||||
<MousePointer2 size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`viewport-btn icon ${transformMode === 'move' ? 'active' : ''}`}
|
|
||||||
onClick={() => setTransformMode('move')}
|
|
||||||
title="移动"
|
|
||||||
>
|
|
||||||
<Move size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`viewport-btn icon ${transformMode === 'rotate' ? 'active' : ''}`}
|
|
||||||
onClick={() => setTransformMode('rotate')}
|
|
||||||
title="旋转"
|
|
||||||
>
|
|
||||||
<RotateCw size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`viewport-btn icon ${transformMode === 'scale' ? 'active' : ''}`}
|
|
||||||
onClick={() => setTransformMode('scale')}
|
|
||||||
title="缩放"
|
|
||||||
>
|
|
||||||
<Maximize2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="viewport-separator" />
|
|
||||||
|
|
||||||
{/* Grid/snap controls */}
|
|
||||||
<div className="viewport-btn-group">
|
<div className="viewport-btn-group">
|
||||||
<button
|
<button
|
||||||
className={`viewport-btn icon ${showGrid ? 'active' : ''}`}
|
className={`viewport-btn icon ${showGrid ? 'active' : ''}`}
|
||||||
@@ -1109,23 +1168,24 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
>
|
>
|
||||||
<Grid3x3 size={14} />
|
<Grid3x3 size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button className="viewport-btn snap-btn" title="位置吸附">
|
<button
|
||||||
<Magnet size={12} />
|
className={`viewport-btn icon ${showCollision ? 'active' : ''}`}
|
||||||
10
|
onClick={() => setShowCollision(!showCollision)}
|
||||||
</button>
|
title="显示碰撞"
|
||||||
<button className="viewport-btn snap-btn" title="旋转吸附">
|
>
|
||||||
<RotateCw size={12} />
|
<Box size={14} />
|
||||||
10°
|
|
||||||
</button>
|
|
||||||
<button className="viewport-btn snap-btn" title="缩放吸附">
|
|
||||||
<Scaling size={12} />
|
|
||||||
0.25
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="viewport-toolbar-center">
|
||||||
|
<button className="viewport-btn" onClick={handleSaveTilemap} title="保存 (Ctrl+S)">
|
||||||
|
<Save size={14} />
|
||||||
|
<span>保存</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="viewport-toolbar-right">
|
<div className="viewport-toolbar-right">
|
||||||
{/* Zoom controls */}
|
|
||||||
<div className="viewport-btn-group">
|
<div className="viewport-btn-group">
|
||||||
<button className="viewport-btn icon" onClick={handleZoomOut} title="缩小">
|
<button className="viewport-btn icon" onClick={handleZoomOut} title="缩小">
|
||||||
<ZoomOut size={14} />
|
<ZoomOut size={14} />
|
||||||
@@ -1158,7 +1218,7 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
<div className="info-item">近似大小: {tilemap.width * tilemap.tileWidth}x{tilemap.height * tilemap.tileHeight}</div>
|
<div className="info-item">近似大小: {tilemap.width * tilemap.tileWidth}x{tilemap.height * tilemap.tileHeight}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Canvas */}
|
{/* Viewport */}
|
||||||
<div className="viewport-canvas-container">
|
<div className="viewport-canvas-container">
|
||||||
<TilemapCanvas
|
<TilemapCanvas
|
||||||
key={tilemapKey}
|
key={tilemapKey}
|
||||||
@@ -1190,8 +1250,10 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
onAddLayer={handleAddLayer}
|
onAddLayer={handleAddLayer}
|
||||||
onRemoveLayer={handleRemoveLayer}
|
onRemoveLayer={handleRemoveLayer}
|
||||||
onMoveLayer={handleMoveLayer}
|
onMoveLayer={handleMoveLayer}
|
||||||
|
onDuplicateLayer={handleDuplicateLayer}
|
||||||
onTilemapChange={handleTilemapChange}
|
onTilemapChange={handleTilemapChange}
|
||||||
onOpenAssetPicker={() => setShowAssetPicker(true)}
|
onOpenAssetPicker={() => setShowAssetPicker(true)}
|
||||||
|
onSelectLayerMaterial={handleSelectLayerMaterial}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1204,6 +1266,15 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
|||||||
fileExtensions={['.png', '.jpg', '.jpeg', '.webp']}
|
fileExtensions={['.png', '.jpg', '.jpeg', '.webp']}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Material Picker Dialog */}
|
||||||
|
<AssetPickerDialog
|
||||||
|
isOpen={showMaterialPicker}
|
||||||
|
onClose={() => setShowMaterialPicker(false)}
|
||||||
|
onSelect={handleMaterialSelected}
|
||||||
|
title="选择图层材质"
|
||||||
|
fileExtensions={['.mat', '.mat.json']}
|
||||||
|
/>
|
||||||
|
|
||||||
<ResizeMapDialog
|
<ResizeMapDialog
|
||||||
isOpen={showResizeDialog}
|
isOpen={showResizeDialog}
|
||||||
onClose={() => setShowResizeDialog(false)}
|
onClose={() => setShowResizeDialog(false)}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { TransformComponent } from '@esengine/engine-core';
|
|||||||
|
|
||||||
// Runtime imports from @esengine/tilemap
|
// Runtime imports from @esengine/tilemap
|
||||||
import { TilemapComponent, TilemapCollider2DComponent, TilemapRuntimeModule } 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 { TilemapEditorPanel } from './components/panels/TilemapEditorPanel';
|
||||||
import { TilemapInspectorProvider } from './providers/TilemapInspectorProvider';
|
import { TilemapInspectorProvider } from './providers/TilemapInspectorProvider';
|
||||||
import { registerTilemapGizmo } from './gizmos/TilemapGizmo';
|
import { registerTilemapGizmo } from './gizmos/TilemapGizmo';
|
||||||
@@ -46,6 +46,7 @@ import './styles/TilemapEditor.css';
|
|||||||
export { TilemapEditorPanel } from './components/panels/TilemapEditorPanel';
|
export { TilemapEditorPanel } from './components/panels/TilemapEditorPanel';
|
||||||
export { TilesetPanel } from './components/panels/TilesetPanel';
|
export { TilesetPanel } from './components/panels/TilesetPanel';
|
||||||
export { TilemapCanvas } from './components/TilemapCanvas';
|
export { TilemapCanvas } from './components/TilemapCanvas';
|
||||||
|
export { TilemapViewport } from './components/TilemapViewport';
|
||||||
export { TilesetPreview } from './components/TilesetPreview';
|
export { TilesetPreview } from './components/TilesetPreview';
|
||||||
export { useTilemapEditorStore } from './stores/TilemapEditorStore';
|
export { useTilemapEditorStore } from './stores/TilemapEditorStore';
|
||||||
export type { TilemapEditorState, TilemapToolType, TileSelection } 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 { BrushTool } from './tools/BrushTool';
|
||||||
export { EraserTool } from './tools/EraserTool';
|
export { EraserTool } from './tools/EraserTool';
|
||||||
export { FillTool } from './tools/FillTool';
|
export { FillTool } from './tools/FillTool';
|
||||||
|
export { RectangleTool } from './tools/RectangleTool';
|
||||||
|
export { SelectTool } from './tools/SelectTool';
|
||||||
export { TilemapInspectorProvider } from './providers/TilemapInspectorProvider';
|
export { TilemapInspectorProvider } from './providers/TilemapInspectorProvider';
|
||||||
|
export { TileAnimationEditor } from './components/panels/TileAnimationEditor';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tilemap 编辑器模块
|
* Tilemap 编辑器模块
|
||||||
@@ -355,22 +359,26 @@ export class TilemapEditorModule implements IEditorModuleLoader {
|
|||||||
export const tilemapEditorModule = new TilemapEditorModule();
|
export const tilemapEditorModule = new TilemapEditorModule();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tilemap 插件描述符
|
* Tilemap 插件清单
|
||||||
* Tilemap Plugin Descriptor
|
* Tilemap Plugin Manifest
|
||||||
*/
|
*/
|
||||||
const descriptor: PluginDescriptor = {
|
const manifest: ModuleManifest = {
|
||||||
id: '@esengine/tilemap',
|
id: '@esengine/tilemap',
|
||||||
name: 'Tilemap',
|
name: '@esengine/tilemap',
|
||||||
|
displayName: 'Tilemap',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
description: 'Tilemap system with Tiled editor support',
|
description: 'Tilemap system with Tiled editor support',
|
||||||
category: 'tilemap',
|
category: 'Rendering',
|
||||||
enabledByDefault: false,
|
isCore: false,
|
||||||
isEnginePlugin: true,
|
defaultEnabled: false,
|
||||||
|
isEngineModule: true,
|
||||||
canContainContent: true,
|
canContainContent: true,
|
||||||
modules: [
|
dependencies: ['engine-core'],
|
||||||
{ name: 'Runtime', type: 'runtime', loadingPhase: 'default' },
|
exports: {
|
||||||
{ name: 'Editor', type: 'editor', loadingPhase: 'postDefault' }
|
components: ['TilemapComponent', 'TilemapCollider2DComponent'],
|
||||||
]
|
systems: ['TilemapRenderingSystem'],
|
||||||
|
loaders: ['TilemapLoader', 'TilesetLoader']
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -378,7 +386,7 @@ const descriptor: PluginDescriptor = {
|
|||||||
* Complete Tilemap Plugin (runtime + editor)
|
* Complete Tilemap Plugin (runtime + editor)
|
||||||
*/
|
*/
|
||||||
export const TilemapPlugin: IPlugin = {
|
export const TilemapPlugin: IPlugin = {
|
||||||
descriptor,
|
manifest,
|
||||||
runtimeModule: new TilemapRuntimeModule(),
|
runtimeModule: new TilemapRuntimeModule(),
|
||||||
editorModule: tilemapEditorModule
|
editorModule: tilemapEditorModule
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface LayerState {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
|
color: string;
|
||||||
|
hiddenInGame: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TilemapEditorState {
|
export interface TilemapEditorState {
|
||||||
@@ -86,6 +88,8 @@ export interface TilemapEditorState {
|
|||||||
toggleLayerVisibility: (index: number) => void;
|
toggleLayerVisibility: (index: number) => void;
|
||||||
toggleLayerLocked: (index: number) => void;
|
toggleLayerLocked: (index: number) => void;
|
||||||
setLayerOpacity: (index: number, opacity: 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;
|
renameLayer: (index: number, name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +219,24 @@ export const useTilemapEditorStore = create<TilemapEditorState>((set, get) => ({
|
|||||||
set({ layers: newLayers });
|
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) => {
|
renameLayer: (index, name) => {
|
||||||
const { layers } = get();
|
const { layers } = get();
|
||||||
const layer = layers[index];
|
const layer = layers[index];
|
||||||
|
|||||||
339
packages/tilemap-editor/src/styles/TileAnimationEditor.css
Normal file
339
packages/tilemap-editor/src/styles/TileAnimationEditor.css
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
/**
|
||||||
|
* Tile Animation Editor Styles
|
||||||
|
* 瓦片动画编辑器样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
.tile-animation-editor-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-animation-editor {
|
||||||
|
background: #252526;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 600px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-editor-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #3c3c3c;
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-editor-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-editor-header .close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #808080;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-editor-header .close-btn:hover {
|
||||||
|
background: #3c3c3c;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-editor-content {
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
gap: 16px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview Section */
|
||||||
|
.animation-preview-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-box {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-preview-canvas {
|
||||||
|
display: block;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-btn {
|
||||||
|
background: #3c3c3c;
|
||||||
|
border: none;
|
||||||
|
color: #e0e0e0;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-btn:hover {
|
||||||
|
background: #4c4c4c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-btn.active {
|
||||||
|
background: #0e639c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-indicator {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Frames Section */
|
||||||
|
.animation-frames-section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frames-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-count {
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frames-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
min-height: 150px;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frames-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #808080;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #252526;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-item:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-item.drag-over {
|
||||||
|
border-color: #0e639c;
|
||||||
|
background: #1e3a5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-drag-handle {
|
||||||
|
color: #606060;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-preview {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-preview canvas {
|
||||||
|
display: block;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-tile-id {
|
||||||
|
color: #808080;
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-duration-input {
|
||||||
|
width: 60px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
color: #e0e0e0;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-duration-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0e639c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-unit {
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-delete-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #808080;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-delete-btn:hover {
|
||||||
|
background: #5a1d1d;
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tileset Section */
|
||||||
|
.animation-tileset-section {
|
||||||
|
border-top: 1px solid #3c3c3c;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileset-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-duration {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-duration input {
|
||||||
|
width: 60px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
color: #e0e0e0;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-duration input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0e639c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileset-scroll-container {
|
||||||
|
max-height: 150px;
|
||||||
|
overflow: auto;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-tileset-canvas {
|
||||||
|
display: block;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.animation-editor-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #3c3c3c;
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #0e639c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #1177bb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #3c3c3c;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #4c4c4c;
|
||||||
|
}
|
||||||
@@ -9,6 +9,62 @@
|
|||||||
user-select: none;
|
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 ==================== */
|
/* ==================== Tool Buttons Row ==================== */
|
||||||
.tileset-tools {
|
.tileset-tools {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -242,6 +298,36 @@
|
|||||||
height: 100%;
|
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 {
|
.tileset-select-btn {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -349,6 +349,136 @@
|
|||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
color: #888;
|
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 ==================== */
|
/* ==================== Layer List ==================== */
|
||||||
@@ -485,6 +615,29 @@
|
|||||||
color: #c0c0c0;
|
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 ==================== */
|
/* ==================== Select Dropdown ==================== */
|
||||||
.property-row select {
|
.property-row select {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
@@ -495,6 +648,57 @@
|
|||||||
cursor: pointer;
|
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 ==================== */
|
/* ==================== Scrollbar ==================== */
|
||||||
.details-content::-webkit-scrollbar,
|
.details-content::-webkit-scrollbar,
|
||||||
.layer-list-container::-webkit-scrollbar {
|
.layer-list-container::-webkit-scrollbar {
|
||||||
|
|||||||
@@ -23,6 +23,57 @@ export class FillTool implements ITilemapTool {
|
|||||||
// No action on up
|
// No action on up
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPreviewTiles(tileX: number, tileY: number, ctx: ToolContext): { x: number; y: number }[] {
|
||||||
|
const { tilemap, editingCollision, currentLayer } = ctx;
|
||||||
|
|
||||||
|
if (tileX < 0 || tileX >= tilemap.width || tileY < 0 || tileY >= tilemap.height) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tiles: { x: number; y: number }[] = [];
|
||||||
|
const maxPreviewTiles = 500;
|
||||||
|
|
||||||
|
if (editingCollision) {
|
||||||
|
const targetCollision = tilemap.hasCollision(tileX, tileY);
|
||||||
|
const stack: [number, number][] = [[tileX, tileY]];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
|
||||||
|
while (stack.length > 0 && tiles.length < maxPreviewTiles) {
|
||||||
|
const [x, y] = stack.pop()!;
|
||||||
|
const key = `${x},${y}`;
|
||||||
|
|
||||||
|
if (visited.has(key)) continue;
|
||||||
|
if (x < 0 || x >= tilemap.width || y < 0 || y >= tilemap.height) continue;
|
||||||
|
if (tilemap.hasCollision(x, y) !== targetCollision) continue;
|
||||||
|
|
||||||
|
visited.add(key);
|
||||||
|
tiles.push({ x, y });
|
||||||
|
|
||||||
|
stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const targetTile = tilemap.getTile(currentLayer, tileX, tileY);
|
||||||
|
const stack: [number, number][] = [[tileX, tileY]];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
|
||||||
|
while (stack.length > 0 && tiles.length < maxPreviewTiles) {
|
||||||
|
const [x, y] = stack.pop()!;
|
||||||
|
const key = `${x},${y}`;
|
||||||
|
|
||||||
|
if (visited.has(key)) continue;
|
||||||
|
if (x < 0 || x >= tilemap.width || y < 0 || y >= tilemap.height) continue;
|
||||||
|
if (tilemap.getTile(currentLayer, x, y) !== targetTile) continue;
|
||||||
|
|
||||||
|
visited.add(key);
|
||||||
|
tiles.push({ x, y });
|
||||||
|
|
||||||
|
stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
private floodFill(startX: number, startY: number, ctx: ToolContext): void {
|
private floodFill(startX: number, startY: number, ctx: ToolContext): void {
|
||||||
const { tilemap, selectedTiles, editingCollision, currentLayer } = ctx;
|
const { tilemap, selectedTiles, editingCollision, currentLayer } = ctx;
|
||||||
|
|
||||||
|
|||||||
100
packages/tilemap-editor/src/tools/RectangleTool.ts
Normal file
100
packages/tilemap-editor/src/tools/RectangleTool.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Rectangle Tool - Draw rectangular areas of tiles
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ITilemapTool, ToolContext } from './ITilemapTool';
|
||||||
|
|
||||||
|
export class RectangleTool implements ITilemapTool {
|
||||||
|
readonly id = 'rectangle';
|
||||||
|
readonly name = 'Rectangle';
|
||||||
|
readonly icon = 'Square';
|
||||||
|
readonly cursor = 'crosshair';
|
||||||
|
|
||||||
|
private _isDrawing = false;
|
||||||
|
private _startX = -1;
|
||||||
|
private _startY = -1;
|
||||||
|
private _currentX = -1;
|
||||||
|
private _currentY = -1;
|
||||||
|
|
||||||
|
onMouseDown(tileX: number, tileY: number, ctx: ToolContext): void {
|
||||||
|
if (ctx.layerLocked && !ctx.editingCollision) return;
|
||||||
|
this._isDrawing = true;
|
||||||
|
this._startX = tileX;
|
||||||
|
this._startY = tileY;
|
||||||
|
this._currentX = tileX;
|
||||||
|
this._currentY = tileY;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseMove(tileX: number, tileY: number, _ctx: ToolContext): void {
|
||||||
|
if (!this._isDrawing) return;
|
||||||
|
this._currentX = tileX;
|
||||||
|
this._currentY = tileY;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseUp(tileX: number, tileY: number, ctx: ToolContext): void {
|
||||||
|
if (!this._isDrawing) return;
|
||||||
|
if (ctx.layerLocked && !ctx.editingCollision) {
|
||||||
|
this.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._currentX = tileX;
|
||||||
|
this._currentY = tileY;
|
||||||
|
this.fillRectangle(ctx);
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
getPreviewTiles(tileX: number, tileY: number, _ctx: ToolContext): { x: number; y: number }[] {
|
||||||
|
if (!this._isDrawing) {
|
||||||
|
return [{ x: tileX, y: tileY }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tiles: { x: number; y: number }[] = [];
|
||||||
|
const minX = Math.min(this._startX, tileX);
|
||||||
|
const maxX = Math.max(this._startX, tileX);
|
||||||
|
const minY = Math.min(this._startY, tileY);
|
||||||
|
const maxY = Math.max(this._startY, tileY);
|
||||||
|
|
||||||
|
for (let y = minY; y <= maxY; y++) {
|
||||||
|
for (let x = minX; x <= maxX; x++) {
|
||||||
|
tiles.push({ x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fillRectangle(ctx: ToolContext): void {
|
||||||
|
const { tilemap, selectedTiles, editingCollision, currentLayer } = ctx;
|
||||||
|
|
||||||
|
const minX = Math.min(this._startX, this._currentX);
|
||||||
|
const maxX = Math.max(this._startX, this._currentX);
|
||||||
|
const minY = Math.min(this._startY, this._currentY);
|
||||||
|
const maxY = Math.max(this._startY, this._currentY);
|
||||||
|
|
||||||
|
for (let y = minY; y <= maxY; y++) {
|
||||||
|
for (let x = minX; x <= maxX; x++) {
|
||||||
|
if (x < 0 || x >= tilemap.width || y < 0 || y >= tilemap.height) continue;
|
||||||
|
|
||||||
|
if (editingCollision) {
|
||||||
|
tilemap.setCollision(x, y, 1);
|
||||||
|
} else if (selectedTiles) {
|
||||||
|
const localX = (x - minX) % selectedTiles.width;
|
||||||
|
const localY = (y - minY) % selectedTiles.height;
|
||||||
|
const tileIndex = selectedTiles.tiles[localY * selectedTiles.width + localX] ?? 0;
|
||||||
|
tilemap.setTile(currentLayer, x, y, tileIndex);
|
||||||
|
} else {
|
||||||
|
tilemap.setTile(currentLayer, x, y, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset(): void {
|
||||||
|
this._isDrawing = false;
|
||||||
|
this._startX = -1;
|
||||||
|
this._startY = -1;
|
||||||
|
this._currentX = -1;
|
||||||
|
this._currentY = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
packages/tilemap-editor/src/tools/SelectTool.ts
Normal file
94
packages/tilemap-editor/src/tools/SelectTool.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Select Tool - Select rectangular regions of tiles for copy/move operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ITilemapTool, ToolContext } from './ITilemapTool';
|
||||||
|
import { useTilemapEditorStore } from '../stores/TilemapEditorStore';
|
||||||
|
|
||||||
|
export class SelectTool implements ITilemapTool {
|
||||||
|
readonly id = 'select';
|
||||||
|
readonly name = 'Select';
|
||||||
|
readonly icon = 'BoxSelect';
|
||||||
|
readonly cursor = 'crosshair';
|
||||||
|
|
||||||
|
private _isSelecting = false;
|
||||||
|
private _startX = -1;
|
||||||
|
private _startY = -1;
|
||||||
|
private _currentX = -1;
|
||||||
|
private _currentY = -1;
|
||||||
|
|
||||||
|
onMouseDown(tileX: number, tileY: number, _ctx: ToolContext): void {
|
||||||
|
this._isSelecting = true;
|
||||||
|
this._startX = tileX;
|
||||||
|
this._startY = tileY;
|
||||||
|
this._currentX = tileX;
|
||||||
|
this._currentY = tileY;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseMove(tileX: number, tileY: number, _ctx: ToolContext): void {
|
||||||
|
if (!this._isSelecting) return;
|
||||||
|
this._currentX = tileX;
|
||||||
|
this._currentY = tileY;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseUp(tileX: number, tileY: number, ctx: ToolContext): void {
|
||||||
|
if (!this._isSelecting) return;
|
||||||
|
|
||||||
|
this._currentX = tileX;
|
||||||
|
this._currentY = tileY;
|
||||||
|
|
||||||
|
const minX = Math.max(0, Math.min(this._startX, this._currentX));
|
||||||
|
const maxX = Math.min(ctx.tilemap.width - 1, Math.max(this._startX, this._currentX));
|
||||||
|
const minY = Math.max(0, Math.min(this._startY, this._currentY));
|
||||||
|
const maxY = Math.min(ctx.tilemap.height - 1, Math.max(this._startY, this._currentY));
|
||||||
|
|
||||||
|
const width = maxX - minX + 1;
|
||||||
|
const height = maxY - minY + 1;
|
||||||
|
|
||||||
|
const tiles: number[] = [];
|
||||||
|
for (let y = minY; y <= maxY; y++) {
|
||||||
|
for (let x = minX; x <= maxX; x++) {
|
||||||
|
const tileIndex = ctx.tilemap.getTile(ctx.currentLayer, x, y);
|
||||||
|
tiles.push(tileIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useTilemapEditorStore.getState().setSelectedTiles({
|
||||||
|
x: minX,
|
||||||
|
y: minY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
tiles
|
||||||
|
});
|
||||||
|
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
getPreviewTiles(tileX: number, tileY: number, _ctx: ToolContext): { x: number; y: number }[] {
|
||||||
|
if (!this._isSelecting) {
|
||||||
|
return [{ x: tileX, y: tileY }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tiles: { x: number; y: number }[] = [];
|
||||||
|
const minX = Math.min(this._startX, tileX);
|
||||||
|
const maxX = Math.max(this._startX, tileX);
|
||||||
|
const minY = Math.min(this._startY, tileY);
|
||||||
|
const maxY = Math.max(this._startY, tileY);
|
||||||
|
|
||||||
|
for (let y = minY; y <= maxY; y++) {
|
||||||
|
for (let x = minX; x <= maxX; x++) {
|
||||||
|
tiles.push({ x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset(): void {
|
||||||
|
this._isSelecting = false;
|
||||||
|
this._startX = -1;
|
||||||
|
this._startY = -1;
|
||||||
|
this._currentX = -1;
|
||||||
|
this._currentY = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
packages/tilemap/module.json
Normal file
44
packages/tilemap/module.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
207
packages/tilemap/src/TilemapAnimationSystem.ts
Normal file
207
packages/tilemap/src/TilemapAnimationSystem.ts
Normal file
@@ -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<string, TileAnimationState> = new Map();
|
||||||
|
|
||||||
|
/** Cached animated tile metadata for quick lookup | 缓存的动画瓦片元数据用于快速查找 */
|
||||||
|
private animatedTiles: Map<string, ITileMetadata> = 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();
|
||||||
@@ -11,6 +11,41 @@ export type ResizeAnchor =
|
|||||||
| 'middle-left' | 'center' | 'middle-right'
|
| 'middle-left' | 'center' | 'middle-right'
|
||||||
| 'bottom-left' | 'bottom-center' | 'bottom-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<string, unknown>;
|
||||||
|
/** Tile animation (if any) | 瓦片动画(如果有) */
|
||||||
|
animation?: ITileAnimation;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tileset data interface
|
* Tileset data interface
|
||||||
* 图块集数据接口
|
* 图块集数据接口
|
||||||
@@ -41,11 +76,7 @@ export interface ITilesetData {
|
|||||||
/** Spacing between tiles in pixels | 图块间距(像素) */
|
/** Spacing between tiles in pixels | 图块间距(像素) */
|
||||||
spacing?: number;
|
spacing?: number;
|
||||||
/** Individual tile metadata | 单个图块元数据 */
|
/** Individual tile metadata | 单个图块元数据 */
|
||||||
tiles?: Array<{
|
tiles?: ITileMetadata[];
|
||||||
id: number;
|
|
||||||
type?: string;
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +100,14 @@ export interface ITilemapLayerData {
|
|||||||
offsetX?: number;
|
offsetX?: number;
|
||||||
/** Layer Y offset in pixels | 图层Y偏移(像素) */
|
/** Layer Y offset in pixels | 图层Y偏移(像素) */
|
||||||
offsetY?: number;
|
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 | 自定义图层属性 */
|
/** Custom layer properties | 自定义图层属性 */
|
||||||
properties?: Record<string, unknown>;
|
properties?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -440,6 +479,40 @@ export class TilemapComponent extends Component implements IResourceComponent {
|
|||||||
return layer;
|
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)
|
* 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 | 瓦片操作 =====
|
// ===== Tile Operations | 瓦片操作 =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -591,6 +754,22 @@ export class TilemapComponent extends Component implements IResourceComponent {
|
|||||||
return this._layersData.get(layer.id);
|
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
|
* 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;
|
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
|
// Mark render data as dirty, needs rebuild
|
||||||
this.renderDirty = true;
|
this.renderDirty = true;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { IScene } from '@esengine/ecs-framework';
|
import type { IScene } from '@esengine/ecs-framework';
|
||||||
import { ComponentRegistry } 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 type { AssetManager } from '@esengine/asset-system';
|
||||||
|
|
||||||
import { TilemapComponent } from './TilemapComponent';
|
import { TilemapComponent } from './TilemapComponent';
|
||||||
@@ -63,18 +63,25 @@ class TilemapRuntimeModule implements IRuntimeModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const descriptor: PluginDescriptor = {
|
const manifest: ModuleManifest = {
|
||||||
id: '@esengine/tilemap',
|
id: 'tilemap',
|
||||||
name: 'Tilemap',
|
name: '@esengine/tilemap',
|
||||||
|
displayName: 'Tilemap 2D',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
description: 'Tilemap system with Tiled editor support',
|
description: 'Tilemap system with Tiled editor support',
|
||||||
category: 'tilemap',
|
category: 'Rendering',
|
||||||
enabledByDefault: false,
|
icon: 'Grid3X3',
|
||||||
isEnginePlugin: true
|
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 = {
|
export const TilemapPlugin: IPlugin = {
|
||||||
descriptor,
|
manifest,
|
||||||
runtimeModule: new TilemapRuntimeModule()
|
runtimeModule: new TilemapRuntimeModule()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ export { TilemapAssetType, TilesetAssetType } from './constants';
|
|||||||
|
|
||||||
// Component
|
// Component
|
||||||
export { TilemapComponent } from './TilemapComponent';
|
export { TilemapComponent } from './TilemapComponent';
|
||||||
export type { ITilemapData, ITilesetData } from './TilemapComponent';
|
export type { ITilemapData, ITilesetData, ITileMetadata, ITileAnimation, ITileAnimationFrame } from './TilemapComponent';
|
||||||
export type { ResizeAnchor } from './TilemapComponent';
|
export type { ResizeAnchor } from './TilemapComponent';
|
||||||
|
|
||||||
|
// Animation System
|
||||||
|
export { TilemapAnimationSystem, tilemapAnimationSystem } from './TilemapAnimationSystem';
|
||||||
|
|
||||||
// Systems
|
// Systems
|
||||||
export { TilemapRenderingSystem } from './systems/TilemapRenderingSystem';
|
export { TilemapRenderingSystem } from './systems/TilemapRenderingSystem';
|
||||||
export type { TilemapRenderData, ViewportBounds } from './systems/TilemapRenderingSystem';
|
export type { TilemapRenderData, ViewportBounds } from './systems/TilemapRenderingSystem';
|
||||||
|
|||||||
@@ -4,11 +4,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IAssetLoadOptions,
|
IAssetLoader,
|
||||||
IAssetMetadata,
|
IAssetContent,
|
||||||
IAssetLoadResult,
|
IAssetParseContext,
|
||||||
AssetLoadError,
|
AssetContentType
|
||||||
IAssetLoader
|
|
||||||
} from '@esengine/asset-system';
|
} from '@esengine/asset-system';
|
||||||
import { TilemapAssetType } from '../constants';
|
import { TilemapAssetType } from '../constants';
|
||||||
|
|
||||||
@@ -39,6 +38,8 @@ export interface ITilemapAsset {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
data?: number[];
|
data?: number[];
|
||||||
|
/** 材质路径 */
|
||||||
|
materialPath?: string;
|
||||||
}>;
|
}>;
|
||||||
/** 碰撞数据(可选) */
|
/** 碰撞数据(可选) */
|
||||||
collisionData?: number[];
|
collisionData?: number[];
|
||||||
@@ -53,78 +54,26 @@ export interface ITilemapAsset {
|
|||||||
export class TilemapLoader implements IAssetLoader<ITilemapAsset> {
|
export class TilemapLoader implements IAssetLoader<ITilemapAsset> {
|
||||||
readonly supportedType = TilemapAssetType;
|
readonly supportedType = TilemapAssetType;
|
||||||
readonly supportedExtensions = ['.tilemap.json', '.tilemap'];
|
readonly supportedExtensions = ['.tilemap.json', '.tilemap'];
|
||||||
|
readonly contentType: AssetContentType = 'text';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load tilemap asset
|
* Parse tilemap asset from text content
|
||||||
* 加载瓦片地图资产
|
* 从文本内容解析瓦片地图资产
|
||||||
*/
|
*/
|
||||||
async load(
|
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<ITilemapAsset> {
|
||||||
path: string,
|
if (!content.text) {
|
||||||
metadata: IAssetMetadata,
|
throw new Error('Tilemap content is empty');
|
||||||
options?: IAssetLoadOptions
|
|
||||||
): Promise<IAssetLoadResult<ITilemapAsset>> {
|
|
||||||
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;
|
const jsonData = JSON.parse(content.text) as ITilemapAsset;
|
||||||
|
|
||||||
// 验证必要字段
|
// 验证必要字段
|
||||||
|
// Validate required fields
|
||||||
if (!jsonData.width || !jsonData.height || !jsonData.data) {
|
if (!jsonData.width || !jsonData.height || !jsonData.data) {
|
||||||
throw new Error('Invalid tilemap format: missing required fields');
|
throw new Error('Invalid tilemap format: missing required fields');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return jsonData;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch with timeout
|
|
||||||
* 带超时的fetch
|
|
||||||
*/
|
|
||||||
private async fetchWithTimeout(url: string, timeout = 30000): Promise<Response> {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
signal: controller.signal,
|
|
||||||
mode: 'cors',
|
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,11 +4,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IAssetLoadOptions,
|
IAssetLoader,
|
||||||
IAssetMetadata,
|
IAssetContent,
|
||||||
IAssetLoadResult,
|
IAssetParseContext,
|
||||||
AssetLoadError,
|
AssetContentType
|
||||||
IAssetLoader
|
|
||||||
} from '@esengine/asset-system';
|
} from '@esengine/asset-system';
|
||||||
import { TilesetAssetType } from '../constants';
|
import { TilesetAssetType } from '../constants';
|
||||||
|
|
||||||
@@ -56,33 +55,27 @@ export interface ITilesetAsset {
|
|||||||
export class TilesetLoader implements IAssetLoader<ITilesetAsset> {
|
export class TilesetLoader implements IAssetLoader<ITilesetAsset> {
|
||||||
readonly supportedType = TilesetAssetType;
|
readonly supportedType = TilesetAssetType;
|
||||||
readonly supportedExtensions = ['.tileset.json', '.tileset'];
|
readonly supportedExtensions = ['.tileset.json', '.tileset'];
|
||||||
|
readonly contentType: AssetContentType = 'text';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load tileset asset
|
* Parse tileset asset from text content
|
||||||
* 加载瓦片集资产
|
* 从文本内容解析瓦片集资产
|
||||||
*/
|
*/
|
||||||
async load(
|
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<ITilesetAsset> {
|
||||||
path: string,
|
if (!content.text) {
|
||||||
metadata: IAssetMetadata,
|
throw new Error('Tileset content is empty');
|
||||||
options?: IAssetLoadOptions
|
|
||||||
): Promise<IAssetLoadResult<ITilesetAsset>> {
|
|
||||||
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;
|
const jsonData = JSON.parse(content.text) as ITilesetAsset;
|
||||||
|
|
||||||
// 验证必要字段
|
// 验证必要字段
|
||||||
|
// Validate required fields
|
||||||
if (!jsonData.tileWidth || !jsonData.tileHeight || !jsonData.image) {
|
if (!jsonData.tileWidth || !jsonData.tileHeight || !jsonData.image) {
|
||||||
throw new Error('Invalid tileset format: missing required fields');
|
throw new Error('Invalid tileset format: missing required fields');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算派生字段(如果未提供)
|
// 计算派生字段(如果未提供)
|
||||||
|
// Calculate derived fields if not provided
|
||||||
if (!jsonData.columns && jsonData.imageWidth) {
|
if (!jsonData.columns && jsonData.imageWidth) {
|
||||||
jsonData.columns = Math.floor(jsonData.imageWidth / jsonData.tileWidth);
|
jsonData.columns = Math.floor(jsonData.imageWidth / jsonData.tileWidth);
|
||||||
}
|
}
|
||||||
@@ -93,52 +86,7 @@ export class TilesetLoader implements IAssetLoader<ITilesetAsset> {
|
|||||||
jsonData.tileCount = jsonData.columns * jsonData.rows;
|
jsonData.tileCount = jsonData.columns * jsonData.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return jsonData;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch with timeout
|
|
||||||
* 带超时的fetch
|
|
||||||
*/
|
|
||||||
private async fetchWithTimeout(url: string, timeout = 30000): Promise<Response> {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
signal: controller.signal,
|
|
||||||
mode: 'cors',
|
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,20 +1,34 @@
|
|||||||
import { EntitySystem, Matcher, ECSSystem, Entity } from '@esengine/ecs-framework';
|
import { EntitySystem, Matcher, ECSSystem, Entity } from '@esengine/ecs-framework';
|
||||||
import { TransformComponent } from '@esengine/engine-core';
|
import { TransformComponent } from '@esengine/engine-core';
|
||||||
|
import { Color } from '@esengine/ecs-framework-math';
|
||||||
import type { IRenderDataProvider } from '@esengine/ecs-engine-bindgen';
|
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 {
|
export interface TilemapRenderData {
|
||||||
|
/** Entity ID | 实体ID */
|
||||||
entityId: number;
|
entityId: number;
|
||||||
|
/** Layer index within the tilemap | 图层在瓦片地图中的索引 */
|
||||||
|
layerIndex: number;
|
||||||
|
/** Transform data [x, y, rotation, scaleX, scaleY, originX, originY] per tile | 每个瓦片的变换数据 */
|
||||||
transforms: Float32Array;
|
transforms: Float32Array;
|
||||||
|
/** Texture IDs per tile | 每个瓦片的纹理ID */
|
||||||
textureIds: Uint32Array;
|
textureIds: Uint32Array;
|
||||||
|
/** UV coordinates [u0, v0, u1, v1] per tile | 每个瓦片的UV坐标 */
|
||||||
uvs: Float32Array;
|
uvs: Float32Array;
|
||||||
|
/** Packed colors (ARGB) per tile | 每个瓦片的打包颜色 */
|
||||||
colors: Uint32Array;
|
colors: Uint32Array;
|
||||||
|
/** Number of tiles in this layer | 此图层的瓦片数量 */
|
||||||
tileCount: number;
|
tileCount: number;
|
||||||
|
/** Sorting order for rendering | 渲染排序顺序 */
|
||||||
sortingOrder: number;
|
sortingOrder: number;
|
||||||
|
/** Texture path for loading | 纹理路径 */
|
||||||
texturePath?: string;
|
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 })
|
@ECSSystem('TilemapRendering', { updateOrder: 40 })
|
||||||
export class TilemapRenderingSystem extends EntitySystem implements IRenderDataProvider {
|
export class TilemapRenderingSystem extends EntitySystem implements IRenderDataProvider {
|
||||||
private _renderDataCache: Map<number, TilemapRenderData> = new Map();
|
/** Cache for layer render data: key = "entityId_layerIndex" | 图层渲染数据缓存 */
|
||||||
|
private _layerRenderDataCache: Map<LayerCacheKey, TilemapRenderData> = new Map();
|
||||||
|
/** Current frame render data | 当前帧渲染数据 */
|
||||||
private _currentFrameData: TilemapRenderData[] = [];
|
private _currentFrameData: TilemapRenderData[] = [];
|
||||||
|
/** Viewport bounds for culling | 视口边界用于剔除 */
|
||||||
private _viewportBounds: ViewportBounds | null = null;
|
private _viewportBounds: ViewportBounds | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(Matcher.empty().all(TilemapComponent, TransformComponent));
|
super(Matcher.empty().all(TilemapComponent, TransformComponent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set viewport bounds for tile culling
|
||||||
|
* 设置视口边界用于瓦片剔除
|
||||||
|
*/
|
||||||
setViewportBounds(bounds: ViewportBounds): void {
|
setViewportBounds(bounds: ViewportBounds): void {
|
||||||
this._viewportBounds = bounds;
|
this._viewportBounds = bounds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get render data for current frame
|
||||||
|
* 获取当前帧的渲染数据
|
||||||
|
*/
|
||||||
getRenderData(): readonly TilemapRenderData[] {
|
getRenderData(): readonly TilemapRenderData[] {
|
||||||
return this._currentFrameData;
|
return this._currentFrameData;
|
||||||
}
|
}
|
||||||
@@ -61,74 +93,95 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP
|
|||||||
|
|
||||||
if (!tilemap || !transform || !tilemap.visible) continue;
|
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;
|
||||||
|
|
||||||
|
const cacheKey: LayerCacheKey = `${entity.id}_${layerIndex}`;
|
||||||
|
let renderData = this._layerRenderDataCache.get(cacheKey);
|
||||||
|
|
||||||
if (!renderData || tilemap.renderDirty) {
|
if (!renderData || tilemap.renderDirty) {
|
||||||
renderData = this.buildRenderData(entity.id, tilemap, transform);
|
renderData = this.buildLayerRenderData(entity.id, layerIndex, tilemap, transform, layer);
|
||||||
this._renderDataCache.set(entity.id, renderData);
|
this._layerRenderDataCache.set(cacheKey, renderData);
|
||||||
tilemap.renderDirty = false;
|
|
||||||
} else {
|
} else {
|
||||||
this.updateTransforms(renderData, tilemap, transform);
|
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);
|
this._currentFrameData.sort((a, b) => a.sortingOrder - b.sortingOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildRenderData(
|
/**
|
||||||
|
* Build render data for a single layer
|
||||||
|
* 为单个图层构建渲染数据
|
||||||
|
*/
|
||||||
|
private buildLayerRenderData(
|
||||||
entityId: number,
|
entityId: number,
|
||||||
|
layerIndex: number,
|
||||||
tilemap: TilemapComponent,
|
tilemap: TilemapComponent,
|
||||||
transform: TransformComponent
|
transform: TransformComponent,
|
||||||
|
layer: ITilemapLayerData
|
||||||
): TilemapRenderData {
|
): 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 width = tilemap.width;
|
||||||
const height = tilemap.height;
|
const height = tilemap.height;
|
||||||
const tileWidth = tilemap.tileWidth;
|
const tileWidth = tilemap.tileWidth;
|
||||||
const tileHeight = tilemap.tileHeight;
|
const tileHeight = tilemap.tileHeight;
|
||||||
|
|
||||||
|
// Calculate visible tile range
|
||||||
// 计算可见瓦片范围
|
// 计算可见瓦片范围
|
||||||
let startCol = 0,
|
const { startCol, endCol, startRow, endRow } = this.calculateVisibleRange(
|
||||||
endCol = width;
|
width, height, tileWidth, tileHeight, transform
|
||||||
let startRow = 0,
|
);
|
||||||
endRow = height;
|
|
||||||
|
|
||||||
if (this._viewportBounds) {
|
// Count non-empty tiles in this layer
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算非空瓦片数量
|
|
||||||
let tileCount = 0;
|
let tileCount = 0;
|
||||||
for (let row = startRow; row < endRow; row++) {
|
for (let row = startRow; row < endRow; row++) {
|
||||||
for (let col = startCol; col < endCol; col++) {
|
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 transforms = new Float32Array(tileCount * 7);
|
||||||
const textureIds = new Uint32Array(tileCount);
|
const textureIds = new Uint32Array(tileCount);
|
||||||
const uvs = new Float32Array(tileCount * 4);
|
const uvs = new Float32Array(tileCount * 4);
|
||||||
const colors = new Uint32Array(tileCount);
|
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
|
// 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 cos = Math.cos(transform.rotation.z);
|
||||||
const sin = Math.sin(transform.rotation.z);
|
const sin = Math.sin(transform.rotation.z);
|
||||||
|
|
||||||
// Tilemap 旋转中心点(左下角为原点,中心在 width/2, height/2 处)
|
// Tilemap rotation pivot
|
||||||
// Tilemap rotation pivot (origin at bottom-left, center at width/2, height/2)
|
// Tilemap 旋转中心点
|
||||||
const pivotX = transform.position.x + (width * tileWidth * transform.scale.x) / 2;
|
const pivotX = transform.position.x + (width * tileWidth * transform.scale.x) / 2;
|
||||||
const pivotY = transform.position.y + (height * tileHeight * transform.scale.y) / 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 row = startRow; row < endRow; row++) {
|
||||||
for (let col = startCol; col < endCol; col++) {
|
for (let col = startCol; col < endCol; col++) {
|
||||||
const gid = mergedData[row * width + col];
|
const gid = layerData[row * width + col];
|
||||||
if (gid <= 0) continue;
|
if (gid <= 0) continue;
|
||||||
|
|
||||||
|
// Find corresponding tileset
|
||||||
// 查找对应的 tileset
|
// 查找对应的 tileset
|
||||||
const tilesetInfo = tilemap.getTilesetForGid(gid);
|
const tilesetInfo = tilemap.getTilesetForGid(gid);
|
||||||
if (!tilesetInfo) {
|
if (!tilesetInfo) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { index: tilesetIndex, localId } = tilesetInfo;
|
const { index: tilesetIndex, localId } = tilesetInfo;
|
||||||
|
|
||||||
|
// Get texture path
|
||||||
// 获取纹理路径
|
// 获取纹理路径
|
||||||
if (!texturePath && tilemap.tilesets[tilesetIndex]) {
|
if (!texturePath && tilemap.tilesets[tilesetIndex]) {
|
||||||
texturePath = tilemap.tilesets[tilesetIndex].source;
|
texturePath = tilemap.tilesets[tilesetIndex].source;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算瓦片的本地位置(相对于 tilemap 中心)
|
|
||||||
// Calculate tile local position (relative to tilemap center)
|
// 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 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;
|
const localY = transform.position.y + (height - 1 - row) * tileHeight * transform.scale.y + (tileHeight * transform.scale.y) / 2 - pivotY;
|
||||||
|
|
||||||
// 应用旋转变换
|
|
||||||
// Apply rotation transform
|
// Apply rotation transform
|
||||||
|
// 应用旋转变换
|
||||||
const rotatedX = localX * cos - localY * sin + pivotX;
|
const rotatedX = localX * cos - localY * sin + pivotX;
|
||||||
const rotatedY = localX * sin + localY * cos + pivotY;
|
const rotatedY = localX * sin + localY * cos + pivotY;
|
||||||
|
|
||||||
// Transform: [x, y, rotation, scaleX, scaleY, originX, originY]
|
// 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;
|
const tOffset = idx * 7;
|
||||||
transforms[tOffset] = rotatedX;
|
transforms[tOffset] = rotatedX;
|
||||||
transforms[tOffset + 1] = rotatedY;
|
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 + 3] = tileWidth * transform.scale.x;
|
||||||
transforms[tOffset + 4] = tileHeight * transform.scale.y;
|
transforms[tOffset + 4] = tileHeight * transform.scale.y;
|
||||||
transforms[tOffset + 5] = 0.5;
|
transforms[tOffset + 5] = 0.5;
|
||||||
transforms[tOffset + 6] = 0.5;
|
transforms[tOffset + 6] = 0.5;
|
||||||
|
|
||||||
// Texture ID (使用 tileset 的 textureId)
|
// Texture ID
|
||||||
textureIds[idx] = tilemap.tilesets[tilesetIndex]?.textureId || 0;
|
textureIds[idx] = tilemap.tilesets[tilesetIndex]?.textureId || 0;
|
||||||
|
|
||||||
// UV coordinates
|
// UV coordinates
|
||||||
@@ -200,33 +251,106 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
entityId,
|
entityId,
|
||||||
|
layerIndex,
|
||||||
transforms,
|
transforms,
|
||||||
textureIds,
|
textureIds,
|
||||||
uvs,
|
uvs,
|
||||||
colors,
|
colors,
|
||||||
tileCount,
|
tileCount,
|
||||||
sortingOrder: tilemap.sortingOrder,
|
sortingOrder: tilemap.sortingOrder + layerIndex * 0.001,
|
||||||
texturePath
|
texturePath,
|
||||||
|
materialId: layer.materialId ?? 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateTransforms(
|
/**
|
||||||
|
* Update transforms for a layer (when only position/rotation/scale changed)
|
||||||
|
* 更新图层的变换(当只有位置/旋转/缩放改变时)
|
||||||
|
*/
|
||||||
|
private updateLayerTransforms(
|
||||||
renderData: TilemapRenderData,
|
renderData: TilemapRenderData,
|
||||||
|
layerIndex: number,
|
||||||
tilemap: TilemapComponent,
|
tilemap: TilemapComponent,
|
||||||
transform: TransformComponent
|
transform: TransformComponent,
|
||||||
|
layer: ITilemapLayerData
|
||||||
): void {
|
): void {
|
||||||
const mergedData = tilemap.getMergedTileData();
|
const layerData = tilemap.getLayerData(layerIndex);
|
||||||
|
if (!layerData) return;
|
||||||
|
|
||||||
const width = tilemap.width;
|
const width = tilemap.width;
|
||||||
const height = tilemap.height;
|
const height = tilemap.height;
|
||||||
const tileWidth = tilemap.tileWidth;
|
const tileWidth = tilemap.tileWidth;
|
||||||
const tileHeight = tilemap.tileHeight;
|
const tileHeight = tilemap.tileHeight;
|
||||||
|
|
||||||
// 计算可见瓦片范围(与 buildRenderData 保持一致)
|
// Calculate visible tile range
|
||||||
// Calculate visible tile range (consistent with buildRenderData)
|
// 计算可见瓦片范围
|
||||||
let startCol = 0,
|
const { startCol, endCol, startRow, endRow } = this.calculateVisibleRange(
|
||||||
endCol = width;
|
width, height, tileWidth, tileHeight, transform
|
||||||
let startRow = 0,
|
);
|
||||||
endRow = height;
|
|
||||||
|
// 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) {
|
if (this._viewportBounds) {
|
||||||
const bounds = 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));
|
endRow = Math.min(height, Math.ceil((bounds.top - mapY) / tileHeight));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算旋转参数
|
return { startCol, endCol, startRow, endRow };
|
||||||
// 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 可能已更改)
|
* Create empty render data
|
||||||
const colorValue = this.parseColor(tilemap.color, tilemap.alpha);
|
* 创建空的渲染数据
|
||||||
for (let i = 0; i < renderData.colors.length; i++) {
|
*/
|
||||||
renderData.colors[i] = colorValue;
|
private createEmptyRenderData(
|
||||||
}
|
entityId: number,
|
||||||
|
layerIndex: number,
|
||||||
renderData.sortingOrder = tilemap.sortingOrder;
|
sortingOrder: number,
|
||||||
}
|
materialId?: number
|
||||||
|
): TilemapRenderData {
|
||||||
private parseColor(hex: string, alpha: number): number {
|
return {
|
||||||
const colorHex = hex.replace('#', '');
|
entityId,
|
||||||
|
layerIndex,
|
||||||
let r = 255,
|
transforms: new Float32Array(0),
|
||||||
g = 255,
|
textureIds: new Uint32Array(0),
|
||||||
b = 255;
|
uvs: new Float32Array(0),
|
||||||
|
colors: new Uint32Array(0),
|
||||||
if (colorHex.length === 6) {
|
tileCount: 0,
|
||||||
r = parseInt(colorHex.substring(0, 2), 16);
|
sortingOrder: sortingOrder + layerIndex * 0.001,
|
||||||
g = parseInt(colorHex.substring(2, 4), 16);
|
materialId: materialId ?? 0
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override onRemoved(entity: Entity): void {
|
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 {
|
clearCache(): void {
|
||||||
this._renderDataCache.clear();
|
this._layerRenderDataCache.clear();
|
||||||
this._currentFrameData = [];
|
this._currentFrameData = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user