refactor: reorganize package structure and decouple framework packages (#338)
* refactor: reorganize package structure and decouple framework packages ## Package Structure Reorganization - Reorganized 55 packages into categorized subdirectories: - packages/framework/ - Generic framework (Laya/Cocos compatible) - packages/engine/ - ESEngine core modules - packages/rendering/ - Rendering modules (WASM dependent) - packages/physics/ - Physics modules - packages/streaming/ - World streaming - packages/network-ext/ - Network extensions - packages/editor/ - Editor framework and plugins - packages/rust/ - Rust WASM engine - packages/tools/ - Build tools and SDK ## Framework Package Decoupling - Decoupled behavior-tree and blueprint packages from ESEngine dependencies - Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent) - ESEngine-specific code moved to esengine/ subpath exports - Framework packages now usable with Cocos/Laya without ESEngine ## CI Configuration - Updated CI to only type-check and lint framework packages - Added type-check:framework and lint:framework scripts ## Breaking Changes - Package import paths changed due to directory reorganization - ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine') * fix: update es-engine file path after directory reorganization * docs: update README to focus on framework over engine * ci: only build framework packages, remove Rust/WASM dependencies * fix: remove esengine subpath from behavior-tree and blueprint builds ESEngine integration code will only be available in full engine builds. Framework packages are now purely engine-agnostic. * fix: move network-protocols to framework, build both in CI * fix: update workflow paths from packages/core to packages/framework/core * fix: exclude esengine folder from type-check in behavior-tree and blueprint * fix: update network tsconfig references to new paths * fix: add test:ci:framework to only test framework packages in CI * fix: only build core and math npm packages in CI * fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
15
packages/editor/plugins/tilemap-editor/module.json
Normal file
15
packages/editor/plugins/tilemap-editor/module.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "tilemap-editor",
|
||||
"name": "@esengine/tilemap-editor",
|
||||
"displayName": "Tilemap Editor",
|
||||
"description": "Editor support for tilemap system | 瓦片地图编辑器支持",
|
||||
"version": "1.0.0",
|
||||
"category": "Editor",
|
||||
"icon": "Grid3X3",
|
||||
"isEditorPlugin": true,
|
||||
"runtimeModule": "@esengine/tilemap",
|
||||
"exports": {
|
||||
"inspectors": ["TilemapComponentInspector", "TilesetInspector"],
|
||||
"panels": ["TilemapEditorPanel"]
|
||||
}
|
||||
}
|
||||
52
packages/editor/plugins/tilemap-editor/package.json
Normal file
52
packages/editor/plugins/tilemap-editor/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@esengine/tilemap-editor",
|
||||
"version": "1.0.0",
|
||||
"description": "Editor support for @esengine/tilemap - tilemap editor, tools, and panels",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:watch": "tsup --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/tilemap": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@esengine/editor-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
"@esengine/editor-runtime": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"lucide-react": "^0.545.0",
|
||||
"react": "^18.3.1",
|
||||
"zustand": "^5.0.8",
|
||||
"@types/react": "^18.3.12",
|
||||
"rimraf": "^5.0.5",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"tilemap",
|
||||
"editor"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* Tilemap Canvas - Main editing canvas
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import type { TilemapComponent } from '@esengine/tilemap';
|
||||
import { tilemapAnimationSystem } from '@esengine/tilemap';
|
||||
import { useTilemapEditorStore } from '../stores/TilemapEditorStore';
|
||||
import type { ITilemapTool, ToolContext } from '../tools/ITilemapTool';
|
||||
import { BrushTool } from '../tools/BrushTool';
|
||||
import { EraserTool } from '../tools/EraserTool';
|
||||
import { FillTool } from '../tools/FillTool';
|
||||
import { RectangleTool } from '../tools/RectangleTool';
|
||||
import { SelectTool } from '../tools/SelectTool';
|
||||
|
||||
interface TilemapCanvasProps {
|
||||
tilemap: TilemapComponent;
|
||||
tilesetImage: HTMLImageElement | null;
|
||||
onTilemapChange?: () => void;
|
||||
}
|
||||
|
||||
const tools: Record<string, ITilemapTool> = {
|
||||
brush: new BrushTool(),
|
||||
eraser: new EraserTool(),
|
||||
fill: new FillTool(),
|
||||
rectangle: new RectangleTool(),
|
||||
select: new SelectTool(),
|
||||
};
|
||||
|
||||
export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
||||
tilemap,
|
||||
tilesetImage,
|
||||
onTilemapChange,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
currentTool,
|
||||
zoom,
|
||||
panX,
|
||||
panY,
|
||||
showGrid,
|
||||
showCollision,
|
||||
selectedTiles,
|
||||
brushSize,
|
||||
currentLayer,
|
||||
editingCollision,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
tilesetColumns,
|
||||
layers,
|
||||
setPan,
|
||||
setZoom,
|
||||
pushUndo,
|
||||
} = useTilemapEditorStore();
|
||||
|
||||
// Get layer locked state
|
||||
const layerLocked = layers[currentLayer]?.locked ?? false;
|
||||
|
||||
// Create a dependency key from layers state to trigger redraw when visibility/opacity changes
|
||||
const layersKey = layers.map(l => `${l.visible}-${l.opacity}`).join(',');
|
||||
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const lastPanPosRef = useRef({ x: 0, y: 0 });
|
||||
const [mousePos, setMousePos] = useState<{ tileX: number; tileY: number } | null>(null);
|
||||
const [spacePressed, setSpacePressed] = useState(false);
|
||||
const [animationTime, setAnimationTime] = useState(0);
|
||||
const lastFrameTimeRef = useRef<number>(0);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
|
||||
// Get canvas size
|
||||
const canvasWidth = tilemap.width * tileWidth;
|
||||
const canvasHeight = tilemap.height * tileHeight;
|
||||
|
||||
// Draw the tilemap
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// Clear
|
||||
ctx.fillStyle = '#2d2d2d';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.translate(panX, panY);
|
||||
ctx.scale(zoom, zoom);
|
||||
|
||||
// Draw tilemap background
|
||||
ctx.fillStyle = '#1a1a1a';
|
||||
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
|
||||
// Draw tiles from all visible layers (from bottom to top)
|
||||
if (tilesetImage) {
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Draw all layers from tilemap component, respecting visibility and opacity
|
||||
const tilemapLayers = tilemap.layers;
|
||||
|
||||
for (let layerIndex = tilemapLayers.length - 1; layerIndex >= 0; layerIndex--) {
|
||||
const tilemapLayer = tilemapLayers[layerIndex];
|
||||
if (!tilemapLayer || !tilemapLayer.visible) continue; // Skip undefined or invisible layers
|
||||
|
||||
// Apply layer opacity
|
||||
const savedAlpha = ctx.globalAlpha;
|
||||
ctx.globalAlpha = tilemapLayer.opacity ?? 1;
|
||||
|
||||
for (let y = 0; y < tilemap.height; y++) {
|
||||
for (let x = 0; x < tilemap.width; x++) {
|
||||
const tileIndex = tilemap.getTile(layerIndex, x, y);
|
||||
if (tileIndex > 0) {
|
||||
// Get the tileset index for this tile (assuming single tileset for now)
|
||||
// tileIndex is 1-based (0 = empty), so tileId = tileIndex - 1
|
||||
const tileId = tileIndex - 1;
|
||||
|
||||
// Get current animation frame tile ID (returns original if not animated)
|
||||
const displayTileId = tilemapAnimationSystem.getCurrentTileId(0, tileId);
|
||||
|
||||
// Calculate source position in tileset
|
||||
const srcX = (displayTileId % tilesetColumns) * tileWidth;
|
||||
const srcY = Math.floor(displayTileId / tilesetColumns) * tileHeight;
|
||||
|
||||
// Only draw if tile is within tileset bounds
|
||||
if (srcX + tileWidth <= tilesetImage.width && srcY + tileHeight <= tilesetImage.height) {
|
||||
ctx.drawImage(
|
||||
tilesetImage,
|
||||
srcX, srcY, tileWidth, tileHeight,
|
||||
x * tileWidth, y * tileHeight, tileWidth, tileHeight
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore opacity
|
||||
ctx.globalAlpha = savedAlpha;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw collision overlay
|
||||
if (showCollision) {
|
||||
ctx.fillStyle = 'rgba(255, 0, 0, 0.3)';
|
||||
for (let y = 0; y < tilemap.height; y++) {
|
||||
for (let x = 0; x < tilemap.width; x++) {
|
||||
if (tilemap.hasCollision(x, y)) {
|
||||
ctx.fillRect(x * tileWidth, y * tileHeight, tileWidth, tileHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw grid
|
||||
if (showGrid) {
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||
ctx.lineWidth = 1 / zoom;
|
||||
|
||||
for (let x = 0; x <= tilemap.width; x++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x * tileWidth, 0);
|
||||
ctx.lineTo(x * tileWidth, canvasHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
for (let y = 0; y <= tilemap.height; y++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y * tileHeight);
|
||||
ctx.lineTo(canvasWidth, y * tileHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Draw tool preview
|
||||
if (mousePos && tools[currentTool]?.getPreviewTiles) {
|
||||
const tool = tools[currentTool];
|
||||
const toolContext: ToolContext = {
|
||||
tilemap,
|
||||
selectedTiles,
|
||||
currentLayer,
|
||||
layerLocked,
|
||||
brushSize,
|
||||
editingCollision,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
};
|
||||
|
||||
const previewTiles = tool.getPreviewTiles!(mousePos.tileX, mousePos.tileY, toolContext);
|
||||
|
||||
ctx.fillStyle = editingCollision ? 'rgba(255, 0, 0, 0.3)' : 'rgba(0, 120, 212, 0.3)';
|
||||
for (const tile of previewTiles) {
|
||||
if (tile.x >= 0 && tile.x < tilemap.width && tile.y >= 0 && tile.y < tilemap.height) {
|
||||
ctx.fillRect(tile.x * tileWidth, tile.y * tileHeight, tileWidth, tileHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}, [tilemap, tilesetImage, zoom, panX, panY, showGrid, showCollision, mousePos, currentTool, selectedTiles, brushSize, currentLayer, layerLocked, editingCollision, tileWidth, tileHeight, tilesetColumns, canvasWidth, canvasHeight, layersKey, animationTime]);
|
||||
|
||||
// Update canvas size
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
let rafId: number | null = null;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
// 使用 requestAnimationFrame 避免 ResizeObserver loop 错误
|
||||
// Use requestAnimationFrame to avoid ResizeObserver loop errors
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const newWidth = container.clientWidth;
|
||||
const newHeight = container.clientHeight;
|
||||
const scaledWidth = Math.floor(newWidth * dpr);
|
||||
const scaledHeight = Math.floor(newHeight * dpr);
|
||||
if (canvas.width !== scaledWidth || canvas.height !== scaledHeight) {
|
||||
canvas.width = scaledWidth;
|
||||
canvas.height = scaledHeight;
|
||||
canvas.style.width = `${newWidth}px`;
|
||||
canvas.style.height = `${newHeight}px`;
|
||||
draw();
|
||||
}
|
||||
rafId = null;
|
||||
});
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [draw]);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
}, [draw]);
|
||||
|
||||
// Register tileset animations when tilemap changes
|
||||
useEffect(() => {
|
||||
tilemapAnimationSystem.clear();
|
||||
for (let i = 0; i < tilemap.tilesets.length; i++) {
|
||||
const tilesetRef = tilemap.tilesets[i];
|
||||
if (tilesetRef.data) {
|
||||
tilemapAnimationSystem.registerTileset(i, tilesetRef.data);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
tilemapAnimationSystem.clear();
|
||||
};
|
||||
}, [tilemap]);
|
||||
|
||||
// Animation loop for animated tiles
|
||||
useEffect(() => {
|
||||
const animate = (time: number) => {
|
||||
if (lastFrameTimeRef.current === 0) {
|
||||
lastFrameTimeRef.current = time;
|
||||
}
|
||||
const deltaTime = time - lastFrameTimeRef.current;
|
||||
lastFrameTimeRef.current = time;
|
||||
|
||||
tilemapAnimationSystem.update(deltaTime);
|
||||
setAnimationTime(time);
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Center view on first mount
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Only center if pan is at default position (0, 0)
|
||||
if (panX === 0 && panY === 0) {
|
||||
const containerWidth = container.clientWidth;
|
||||
const containerHeight = container.clientHeight;
|
||||
const mapPixelWidth = canvasWidth * zoom;
|
||||
const mapPixelHeight = canvasHeight * zoom;
|
||||
|
||||
const centerX = (containerWidth - mapPixelWidth) / 2;
|
||||
const centerY = (containerHeight - mapPixelHeight) / 2;
|
||||
|
||||
setPan(centerX, centerY);
|
||||
}
|
||||
}, []); // Only run on mount
|
||||
|
||||
// Space key for panning mode
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Space' && !e.repeat) {
|
||||
e.preventDefault();
|
||||
setSpacePressed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Space') {
|
||||
setSpacePressed(false);
|
||||
setIsPanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Convert screen coordinates to tile coordinates
|
||||
const screenToTile = useCallback((screenX: number, screenY: number) => {
|
||||
const x = (screenX - panX) / zoom;
|
||||
const y = (screenY - panY) / zoom;
|
||||
return {
|
||||
tileX: Math.floor(x / tileWidth),
|
||||
tileY: Math.floor(y / tileHeight),
|
||||
};
|
||||
}, [panX, panY, zoom, tileWidth, tileHeight]);
|
||||
|
||||
// Mouse handlers
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// Middle mouse button, Alt+left click, or Space+left click for panning
|
||||
if (e.button === 1 || (e.button === 0 && (e.altKey || spacePressed))) {
|
||||
setIsPanning(true);
|
||||
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
return;
|
||||
}
|
||||
|
||||
// Save undo state
|
||||
const layerData = tilemap.getLayerData(currentLayer);
|
||||
if (layerData) {
|
||||
pushUndo(layerData.slice());
|
||||
}
|
||||
|
||||
const { tileX, tileY } = screenToTile(x, y);
|
||||
const tool = tools[currentTool];
|
||||
if (tool) {
|
||||
const toolContext: ToolContext = {
|
||||
tilemap,
|
||||
selectedTiles,
|
||||
currentLayer,
|
||||
layerLocked,
|
||||
brushSize,
|
||||
editingCollision,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
};
|
||||
tool.onMouseDown(tileX, tileY, toolContext);
|
||||
onTilemapChange?.();
|
||||
// draw() 由 useEffect 统一处理,避免重复绘制导致闪烁
|
||||
}
|
||||
};
|
||||
|
||||
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 - lastPanPosRef.current.x;
|
||||
const dy = e.clientY - lastPanPosRef.current.y;
|
||||
const state = useTilemapEditorStore.getState();
|
||||
setPan(state.panX + dx, state.panY + dy);
|
||||
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
return;
|
||||
}
|
||||
|
||||
const { tileX, tileY } = screenToTile(x, y);
|
||||
setMousePos({ tileX, tileY });
|
||||
|
||||
// Handle tool drag
|
||||
if (e.buttons === 1) {
|
||||
const tool = tools[currentTool];
|
||||
if (tool) {
|
||||
const toolContext: ToolContext = {
|
||||
tilemap,
|
||||
selectedTiles,
|
||||
currentLayer,
|
||||
layerLocked,
|
||||
brushSize,
|
||||
editingCollision,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
};
|
||||
tool.onMouseMove(tileX, tileY, toolContext);
|
||||
onTilemapChange?.();
|
||||
}
|
||||
}
|
||||
// draw() 由 setMousePos 触发的 useEffect 统一处理
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: React.MouseEvent) => {
|
||||
if (isPanning) {
|
||||
setIsPanning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const { tileX, tileY } = screenToTile(x, y);
|
||||
|
||||
const tool = tools[currentTool];
|
||||
if (tool) {
|
||||
const toolContext: ToolContext = {
|
||||
tilemap,
|
||||
selectedTiles,
|
||||
currentLayer,
|
||||
layerLocked,
|
||||
brushSize,
|
||||
editingCollision,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
};
|
||||
tool.onMouseUp(tileX, tileY, toolContext);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setMousePos(null);
|
||||
// draw() 由 setMousePos 触发的 useEffect 统一处理
|
||||
};
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newZoom = Math.max(0.1, Math.min(10, zoom * delta));
|
||||
|
||||
// Zoom towards mouse position
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
const newPanX = mouseX - (mouseX - panX) * (newZoom / zoom);
|
||||
const newPanY = mouseY - (mouseY - panY) * (newZoom / zoom);
|
||||
setPan(newPanX, newPanY);
|
||||
}
|
||||
|
||||
setZoom(newZoom);
|
||||
};
|
||||
|
||||
// Determine cursor style
|
||||
const getCursor = () => {
|
||||
if (isPanning) return 'grabbing';
|
||||
if (spacePressed) return 'grab';
|
||||
return tools[currentTool]?.cursor || 'crosshair';
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="tilemap-canvas-container">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="tilemap-canvas"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
style={{ cursor: getCursor() }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Tilemap Viewport - Engine-based rendering for tilemap editor
|
||||
* Tilemap 视口 - 基于引擎的瓦片地图编辑器渲染
|
||||
*
|
||||
* Uses the same rendering pipeline as the main editor viewport.
|
||||
* 使用与主编辑器视口相同的渲染管线。
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { IViewportService_ID, type IViewportService } from '@esengine/editor-core';
|
||||
import type { TilemapComponent } from '@esengine/tilemap';
|
||||
import { useTilemapEditorStore } from '../stores/TilemapEditorStore';
|
||||
import type { ITilemapTool, ToolContext } from '../tools/ITilemapTool';
|
||||
import { BrushTool } from '../tools/BrushTool';
|
||||
import { EraserTool } from '../tools/EraserTool';
|
||||
import { FillTool } from '../tools/FillTool';
|
||||
|
||||
interface TilemapViewportProps {
|
||||
tilemap: TilemapComponent;
|
||||
onTilemapChange?: () => void;
|
||||
}
|
||||
|
||||
const VIEWPORT_ID = 'tilemap-editor-viewport';
|
||||
const CANVAS_ID = 'tilemap-editor-canvas';
|
||||
|
||||
const tools: Record<string, ITilemapTool> = {
|
||||
brush: new BrushTool(),
|
||||
eraser: new EraserTool(),
|
||||
fill: new FillTool(),
|
||||
};
|
||||
|
||||
export const TilemapViewport: React.FC<TilemapViewportProps> = ({
|
||||
tilemap,
|
||||
onTilemapChange,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewportServiceRef = useRef<IViewportService | null>(null);
|
||||
const registeredRef = useRef(false);
|
||||
|
||||
const {
|
||||
currentTool,
|
||||
zoom,
|
||||
panX,
|
||||
panY,
|
||||
showGrid,
|
||||
showCollision: _showCollision,
|
||||
selectedTiles,
|
||||
brushSize,
|
||||
currentLayer,
|
||||
editingCollision,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
layers,
|
||||
setPan,
|
||||
setZoom,
|
||||
pushUndo,
|
||||
} = useTilemapEditorStore();
|
||||
|
||||
// Get layer locked state
|
||||
const layerLocked = layers[currentLayer]?.locked ?? false;
|
||||
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [lastPanPos, setLastPanPos] = useState({ x: 0, y: 0 });
|
||||
const [_mousePos, setMousePos] = useState<{ tileX: number; tileY: number } | null>(null);
|
||||
const [spacePressed, setSpacePressed] = useState(false);
|
||||
|
||||
// Get canvas size (reserved for future virtual scrolling)
|
||||
const _canvasWidth = tilemap.width * tileWidth;
|
||||
const _canvasHeight = tilemap.height * tileHeight;
|
||||
|
||||
// Initialize viewport service
|
||||
useEffect(() => {
|
||||
const service = Core.services.tryResolve<IViewportService>(IViewportService_ID);
|
||||
viewportServiceRef.current = service ?? null;
|
||||
|
||||
if (!service) {
|
||||
console.warn('[TilemapViewport] ViewportService not available');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Register viewport when canvas is ready
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
const service = viewportServiceRef.current;
|
||||
|
||||
if (!canvas || !container || !service) return;
|
||||
|
||||
// Wait for engine to be initialized
|
||||
if (!service.isInitialized()) {
|
||||
const checkInit = setInterval(() => {
|
||||
if (service.isInitialized() && !registeredRef.current) {
|
||||
clearInterval(checkInit);
|
||||
setupViewport();
|
||||
}
|
||||
}, 100);
|
||||
return () => clearInterval(checkInit);
|
||||
}
|
||||
|
||||
setupViewport();
|
||||
|
||||
function setupViewport() {
|
||||
if (registeredRef.current || !canvas || !container || !service) return;
|
||||
|
||||
// Set canvas size
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = container.getBoundingClientRect();
|
||||
canvas.width = Math.floor(rect.width * dpr);
|
||||
canvas.height = Math.floor(rect.height * dpr);
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
|
||||
// Register viewport
|
||||
service.registerViewport(VIEWPORT_ID, CANVAS_ID);
|
||||
service.setViewportConfig(VIEWPORT_ID, showGrid, false); // No gizmos in tilemap editor
|
||||
service.resizeViewport(VIEWPORT_ID, canvas.width, canvas.height);
|
||||
|
||||
registeredRef.current = true;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (registeredRef.current && service) {
|
||||
service.unregisterViewport(VIEWPORT_ID);
|
||||
registeredRef.current = false;
|
||||
}
|
||||
};
|
||||
}, [showGrid]);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const service = viewportServiceRef.current;
|
||||
|
||||
if (!container || !canvas || !service || !registeredRef.current) return;
|
||||
|
||||
let rafId: number | null = null;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const newWidth = Math.floor(rect.width * dpr);
|
||||
const newHeight = Math.floor(rect.height * dpr);
|
||||
|
||||
if (canvas.width !== newWidth || canvas.height !== newHeight) {
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
service.resizeViewport(VIEWPORT_ID, newWidth, newHeight);
|
||||
}
|
||||
rafId = null;
|
||||
});
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update camera when pan/zoom changes
|
||||
useEffect(() => {
|
||||
const service = viewportServiceRef.current;
|
||||
if (!service || !registeredRef.current) return;
|
||||
|
||||
// Convert pan to camera position
|
||||
// In engine, camera position is the center of view
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const centerX = (canvas.width / 2 - panX) / zoom;
|
||||
const centerY = (canvas.height / 2 - panY) / zoom;
|
||||
|
||||
service.setViewportCamera(VIEWPORT_ID, {
|
||||
x: centerX,
|
||||
y: -centerY, // Y is flipped
|
||||
zoom: zoom
|
||||
});
|
||||
}, [panX, panY, zoom]);
|
||||
|
||||
// Update grid visibility
|
||||
useEffect(() => {
|
||||
const service = viewportServiceRef.current;
|
||||
if (!service || !registeredRef.current) return;
|
||||
|
||||
service.setViewportConfig(VIEWPORT_ID, showGrid, false);
|
||||
}, [showGrid]);
|
||||
|
||||
// Space key for panning mode
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Space' && !e.repeat) {
|
||||
e.preventDefault();
|
||||
setSpacePressed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Space') {
|
||||
setSpacePressed(false);
|
||||
setIsPanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Convert screen coordinates to tile coordinates
|
||||
const screenToTile = useCallback((screenX: number, screenY: number) => {
|
||||
const x = (screenX - panX) / zoom;
|
||||
const y = (screenY - panY) / zoom;
|
||||
return {
|
||||
tileX: Math.floor(x / tileWidth),
|
||||
tileY: Math.floor(y / tileHeight),
|
||||
};
|
||||
}, [panX, panY, zoom, tileWidth, tileHeight]);
|
||||
|
||||
// Mouse handlers
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// Middle mouse button, Alt+left click, or Space+left click for panning
|
||||
if (e.button === 1 || (e.button === 0 && (e.altKey || spacePressed))) {
|
||||
setIsPanning(true);
|
||||
setLastPanPos({ x: e.clientX, y: e.clientY });
|
||||
return;
|
||||
}
|
||||
|
||||
// Save undo state
|
||||
const layerData = tilemap.getLayerData(currentLayer);
|
||||
if (layerData) {
|
||||
pushUndo(layerData.slice());
|
||||
}
|
||||
|
||||
const { tileX, tileY } = screenToTile(x, y);
|
||||
const tool = tools[currentTool];
|
||||
if (tool) {
|
||||
const toolContext: ToolContext = {
|
||||
tilemap,
|
||||
selectedTiles,
|
||||
currentLayer,
|
||||
layerLocked,
|
||||
brushSize,
|
||||
editingCollision,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
};
|
||||
tool.onMouseDown(tileX, tileY, toolContext);
|
||||
onTilemapChange?.();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// Handle panning
|
||||
if (isPanning) {
|
||||
const dx = e.clientX - lastPanPos.x;
|
||||
const dy = e.clientY - lastPanPos.y;
|
||||
const state = useTilemapEditorStore.getState();
|
||||
setPan(state.panX + dx, state.panY + dy);
|
||||
setLastPanPos({ x: e.clientX, y: e.clientY });
|
||||
return;
|
||||
}
|
||||
|
||||
const { tileX, tileY } = screenToTile(x, y);
|
||||
setMousePos({ tileX, tileY });
|
||||
|
||||
// Handle tool drag
|
||||
if (e.buttons === 1) {
|
||||
const tool = tools[currentTool];
|
||||
if (tool) {
|
||||
const toolContext: ToolContext = {
|
||||
tilemap,
|
||||
selectedTiles,
|
||||
currentLayer,
|
||||
layerLocked,
|
||||
brushSize,
|
||||
editingCollision,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
};
|
||||
tool.onMouseMove(tileX, tileY, toolContext);
|
||||
onTilemapChange?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: React.MouseEvent) => {
|
||||
if (isPanning) {
|
||||
setIsPanning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const { tileX, tileY } = screenToTile(x, y);
|
||||
|
||||
const tool = tools[currentTool];
|
||||
if (tool) {
|
||||
const toolContext: ToolContext = {
|
||||
tilemap,
|
||||
selectedTiles,
|
||||
currentLayer,
|
||||
layerLocked,
|
||||
brushSize,
|
||||
editingCollision,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
};
|
||||
tool.onMouseUp(tileX, tileY, toolContext);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setMousePos(null);
|
||||
};
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newZoom = Math.max(0.1, Math.min(10, zoom * delta));
|
||||
|
||||
// Zoom towards mouse position
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
const newPanX = mouseX - (mouseX - panX) * (newZoom / zoom);
|
||||
const newPanY = mouseY - (mouseY - panY) * (newZoom / zoom);
|
||||
setPan(newPanX, newPanY);
|
||||
}
|
||||
|
||||
setZoom(newZoom);
|
||||
};
|
||||
|
||||
// Determine cursor style
|
||||
const getCursor = () => {
|
||||
if (isPanning) return 'grabbing';
|
||||
if (spacePressed) return 'grab';
|
||||
return tools[currentTool]?.cursor || 'crosshair';
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="tilemap-canvas-container">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
id={CANVAS_ID}
|
||||
className="tilemap-canvas"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
style={{ cursor: getCursor() }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Tileset Preview Component - Display and select tiles from a tileset
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { useTilemapEditorStore, type TileSelection } from '../stores/TilemapEditorStore';
|
||||
import type { ITilesetData, ITileAnimation } from '@esengine/tilemap';
|
||||
|
||||
interface TilesetPreviewProps {
|
||||
imageUrl: string;
|
||||
tileWidth: number;
|
||||
tileHeight: number;
|
||||
columns: number;
|
||||
rows: number;
|
||||
tileset?: ITilesetData;
|
||||
animatedTileIds?: Set<number>;
|
||||
onSelectionChange?: (selection: TileSelection) => void;
|
||||
onEditAnimation?: (tileId: number) => void;
|
||||
}
|
||||
|
||||
export const TilesetPreview: React.FC<TilesetPreviewProps> = ({
|
||||
imageUrl,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
columns,
|
||||
rows,
|
||||
tileset,
|
||||
animatedTileIds,
|
||||
onSelectionChange,
|
||||
onEditAnimation,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
|
||||
const [selectionEnd, setSelectionEnd] = useState<{ x: number; y: number } | null>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tileId: number } | null>(null);
|
||||
|
||||
const selectedTiles = useTilemapEditorStore(state => state.selectedTiles);
|
||||
const setSelectedTiles = useTilemapEditorStore(state => state.setSelectedTiles);
|
||||
|
||||
// Load image
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.onload = () => setImage(img);
|
||||
img.src = imageUrl;
|
||||
}, [imageUrl]);
|
||||
|
||||
// Draw tileset
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !image) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size based on actual image size (+1 for border lines)
|
||||
canvas.width = image.width + 1;
|
||||
canvas.height = image.height + 1;
|
||||
|
||||
// Draw image
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
// Draw grid only within the actual tileset area
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let x = 0; x <= columns; x++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x * tileWidth + 0.5, 0);
|
||||
ctx.lineTo(x * tileWidth + 0.5, image.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
for (let y = 0; y <= rows; y++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y * tileHeight + 0.5);
|
||||
ctx.lineTo(image.width, y * tileHeight + 0.5);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw selection preview during drag
|
||||
if (isSelecting && selectionStart && selectionEnd) {
|
||||
const minX = Math.min(selectionStart.x, selectionEnd.x);
|
||||
const maxX = Math.max(selectionStart.x, selectionEnd.x);
|
||||
const minY = Math.min(selectionStart.y, selectionEnd.y);
|
||||
const maxY = Math.max(selectionStart.y, selectionEnd.y);
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 120, 212, 0.3)';
|
||||
ctx.fillRect(
|
||||
minX * tileWidth,
|
||||
minY * tileHeight,
|
||||
(maxX - minX + 1) * tileWidth,
|
||||
(maxY - minY + 1) * tileHeight
|
||||
);
|
||||
}
|
||||
|
||||
// Draw current selection
|
||||
if (selectedTiles && !isSelecting) {
|
||||
ctx.strokeStyle = '#0078d4';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(
|
||||
selectedTiles.x * tileWidth + 1,
|
||||
selectedTiles.y * tileHeight + 1,
|
||||
selectedTiles.width * tileWidth - 2,
|
||||
selectedTiles.height * tileHeight - 2
|
||||
);
|
||||
}
|
||||
|
||||
// Draw animation indicators
|
||||
if (animatedTileIds && animatedTileIds.size > 0) {
|
||||
for (const tileId of animatedTileIds) {
|
||||
const x = (tileId % columns) * tileWidth;
|
||||
const y = Math.floor(tileId / columns) * tileHeight;
|
||||
|
||||
// Draw small play icon in bottom-right corner
|
||||
ctx.fillStyle = 'rgba(0, 180, 0, 0.9)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + tileWidth - 12, y + tileHeight - 10);
|
||||
ctx.lineTo(x + tileWidth - 12, y + tileHeight - 2);
|
||||
ctx.lineTo(x + tileWidth - 4, y + tileHeight - 6);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}, [image, columns, rows, tileWidth, tileHeight, selectedTiles, isSelecting, selectionStart, selectionEnd, animatedTileIds]);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
}, [draw]);
|
||||
|
||||
const getTileCoords = (e: React.MouseEvent): { x: number; y: number } => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const x = Math.floor((e.clientX - rect.left) * scaleX / tileWidth);
|
||||
const y = Math.floor((e.clientY - rect.top) * scaleY / tileHeight);
|
||||
|
||||
return {
|
||||
x: Math.max(0, Math.min(columns - 1, x)),
|
||||
y: Math.max(0, Math.min(rows - 1, y)),
|
||||
};
|
||||
};
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
// Only zoom when Ctrl is pressed
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
setZoom(z => Math.max(0.5, Math.min(5, z * delta)));
|
||||
}
|
||||
// Otherwise let the default scroll behavior work
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
const coords = getTileCoords(e);
|
||||
setIsSelecting(true);
|
||||
setSelectionStart(coords);
|
||||
setSelectionEnd(coords);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isSelecting) return;
|
||||
const coords = getTileCoords(e);
|
||||
setSelectionEnd(coords);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!isSelecting || !selectionStart || !selectionEnd) {
|
||||
setIsSelecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const minX = Math.min(selectionStart.x, selectionEnd.x);
|
||||
const maxX = Math.max(selectionStart.x, selectionEnd.x);
|
||||
const minY = Math.min(selectionStart.y, selectionEnd.y);
|
||||
const maxY = Math.max(selectionStart.y, selectionEnd.y);
|
||||
|
||||
const width = maxX - minX + 1;
|
||||
const height = maxY - minY + 1;
|
||||
const tiles: number[] = [];
|
||||
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
// Tile index = y * columns + x + 1 (0 is empty)
|
||||
tiles.push(y * columns + x + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const selection: TileSelection = {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width,
|
||||
height,
|
||||
tiles,
|
||||
};
|
||||
|
||||
setSelectedTiles(selection);
|
||||
onSelectionChange?.(selection);
|
||||
|
||||
setIsSelecting(false);
|
||||
setSelectionStart(null);
|
||||
setSelectionEnd(null);
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!onEditAnimation) return;
|
||||
|
||||
const coords = getTileCoords(e);
|
||||
const tileId = coords.y * columns + coords.x;
|
||||
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
tileId
|
||||
});
|
||||
};
|
||||
|
||||
const _handleCloseContextMenu = () => {
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleEditAnimation = () => {
|
||||
if (contextMenu && onEditAnimation) {
|
||||
onEditAnimation(contextMenu.tileId);
|
||||
}
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
// Close context menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClick = () => setContextMenu(null);
|
||||
if (contextMenu) {
|
||||
document.addEventListener('click', handleClick);
|
||||
return () => document.removeEventListener('click', handleClick);
|
||||
}
|
||||
}, [contextMenu]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="tileset-canvas"
|
||||
style={{
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="tileset-context-menu"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
background: '#252526',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 0',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '6px 16px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '12px',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = '#094771'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'none'}
|
||||
onClick={handleEditAnimation}
|
||||
>
|
||||
编辑动画... (瓦片 #{contextMenu.tileId})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Layer Panel Component
|
||||
* 图层面板组件
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Eye, EyeOff, Lock, Unlock, Plus, Trash2, ChevronUp, ChevronDown, Paintbrush, Shield, Grid3X3 } from 'lucide-react';
|
||||
import { useTilemapEditorStore, type LayerState } from '../../stores/TilemapEditorStore';
|
||||
import type { TilemapComponent } from '@esengine/tilemap';
|
||||
|
||||
interface LayerPanelProps {
|
||||
tilemap: TilemapComponent | null;
|
||||
onAddLayer?: () => void;
|
||||
onRemoveLayer?: (index: number) => void;
|
||||
onMoveLayer?: (fromIndex: number, toIndex: number) => void;
|
||||
}
|
||||
|
||||
export const LayerPanel: React.FC<LayerPanelProps> = ({
|
||||
tilemap,
|
||||
onAddLayer,
|
||||
onRemoveLayer,
|
||||
onMoveLayer,
|
||||
}) => {
|
||||
const {
|
||||
currentLayer,
|
||||
layers,
|
||||
setCurrentLayer,
|
||||
toggleLayerVisibility,
|
||||
toggleLayerLocked,
|
||||
setLayerOpacity,
|
||||
renameLayer,
|
||||
showCollision,
|
||||
setShowCollision,
|
||||
editingCollision,
|
||||
setEditingCollision,
|
||||
} = useTilemapEditorStore();
|
||||
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
|
||||
const handleDoubleClick = useCallback((index: number, name: string) => {
|
||||
setEditingIndex(index);
|
||||
setEditName(name);
|
||||
}, []);
|
||||
|
||||
const handleNameSubmit = useCallback((index: number) => {
|
||||
if (editName.trim()) {
|
||||
renameLayer(index, editName.trim());
|
||||
// Also update the tilemap component
|
||||
if (tilemap && tilemap.layers[index]) {
|
||||
tilemap.layers[index].name = editName.trim();
|
||||
}
|
||||
}
|
||||
setEditingIndex(null);
|
||||
}, [editName, renameLayer, tilemap]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent, index: number) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleNameSubmit(index);
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingIndex(null);
|
||||
}
|
||||
}, [handleNameSubmit]);
|
||||
|
||||
const handleVisibilityToggle = useCallback((index: number) => {
|
||||
toggleLayerVisibility(index);
|
||||
// Also update the tilemap component
|
||||
if (tilemap && tilemap.layers[index]) {
|
||||
tilemap.layers[index].visible = !tilemap.layers[index].visible;
|
||||
tilemap.renderDirty = true;
|
||||
}
|
||||
}, [toggleLayerVisibility, tilemap]);
|
||||
|
||||
const handleOpacityChange = useCallback((index: number, opacity: number) => {
|
||||
setLayerOpacity(index, opacity);
|
||||
// Also update the tilemap component
|
||||
if (tilemap && tilemap.layers[index]) {
|
||||
tilemap.layers[index].opacity = opacity;
|
||||
tilemap.renderDirty = true;
|
||||
}
|
||||
}, [setLayerOpacity, tilemap]);
|
||||
|
||||
if (!tilemap || layers.length === 0) {
|
||||
return (
|
||||
<div className="layer-panel">
|
||||
<div className="layer-panel-header">
|
||||
<span>图层</span>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={onAddLayer}
|
||||
title="添加图层"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="layer-panel-empty">
|
||||
暂无图层
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="layer-panel">
|
||||
<div className="layer-panel-header">
|
||||
<span>图层 ({layers.length})</span>
|
||||
<div className="layer-panel-actions">
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={onAddLayer}
|
||||
title="添加图层"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="layer-list">
|
||||
{/* Collision Layer - Special layer */}
|
||||
<div
|
||||
className={`layer-item collision-layer ${editingCollision ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
setEditingCollision(true);
|
||||
// Auto-show collision when editing
|
||||
if (!showCollision) {
|
||||
setShowCollision(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="layer-controls">
|
||||
<button
|
||||
className="icon-button small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowCollision(!showCollision);
|
||||
}}
|
||||
title={showCollision ? '隐藏碰撞层' : '显示碰撞层'}
|
||||
>
|
||||
{showCollision ? <Eye size={12} /> : <EyeOff size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="layer-info">
|
||||
{editingCollision && (
|
||||
<span className="layer-active-indicator" title="当前编辑碰撞">
|
||||
<Shield size={14} />
|
||||
</span>
|
||||
)}
|
||||
<span className="layer-name collision-name">
|
||||
<Shield size={12} style={{ marginRight: 4, opacity: 0.7 }} />
|
||||
碰撞层
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="layer-separator" />
|
||||
|
||||
{/* Tile Layers */}
|
||||
{layers.map((layer, index) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`layer-item ${index === currentLayer && !editingCollision ? 'selected' : ''} ${layer.locked ? 'locked' : ''}`}
|
||||
onClick={() => {
|
||||
setEditingCollision(false);
|
||||
setCurrentLayer(index);
|
||||
}}
|
||||
>
|
||||
<div className="layer-controls">
|
||||
<button
|
||||
className="icon-button small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleVisibilityToggle(index);
|
||||
}}
|
||||
title={layer.visible ? '隐藏图层' : '显示图层'}
|
||||
>
|
||||
{layer.visible ? <Eye size={12} /> : <EyeOff size={12} />}
|
||||
</button>
|
||||
<button
|
||||
className="icon-button small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleLayerLocked(index);
|
||||
}}
|
||||
title={layer.locked ? '解锁图层' : '锁定图层'}
|
||||
>
|
||||
{layer.locked ? <Lock size={12} /> : <Unlock size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="layer-info">
|
||||
{index === currentLayer && (
|
||||
<span className="layer-active-indicator" title="当前绘制图层">
|
||||
<Paintbrush size={14} />
|
||||
</span>
|
||||
)}
|
||||
{editingIndex === index ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onBlur={() => handleNameSubmit(index)}
|
||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||
autoFocus
|
||||
className="layer-name-input"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="layer-name"
|
||||
onDoubleClick={() => handleDoubleClick(index, layer.name)}
|
||||
>
|
||||
{layer.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="layer-actions">
|
||||
<button
|
||||
className="icon-button small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveLayer?.(index, index - 1);
|
||||
}}
|
||||
disabled={index === 0}
|
||||
title="上移图层"
|
||||
>
|
||||
<ChevronUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveLayer?.(index, index + 1);
|
||||
}}
|
||||
disabled={index === layers.length - 1}
|
||||
title="下移图层"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button small danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveLayer?.(index);
|
||||
}}
|
||||
disabled={layers.length <= 1}
|
||||
title="删除图层"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Opacity slider for selected layer */}
|
||||
{layers[currentLayer] && (
|
||||
<div className="layer-opacity-control">
|
||||
<label>Opacity</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={layers[currentLayer].opacity}
|
||||
onChange={(e) => handleOpacityChange(currentLayer, parseFloat(e.target.value))}
|
||||
title={`Opacity for ${layers[currentLayer].name}`}
|
||||
/>
|
||||
<span>{Math.round(layers[currentLayer].opacity * 100)}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayerPanel;
|
||||
@@ -0,0 +1,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;
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Tile Set Selector Panel - Left panel for selecting tiles
|
||||
* 瓦片集选择面板 - 左侧面板用于选择瓦片
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Paintbrush, Eraser, PaintBucket, ChevronDown, Grid3x3, Search, Box, Square, BoxSelect } from 'lucide-react';
|
||||
import { useTilemapEditorStore, type TilemapToolType } from '../../stores/TilemapEditorStore';
|
||||
import { TilesetPreview } from '../TilesetPreview';
|
||||
import { TileAnimationEditor } from './TileAnimationEditor';
|
||||
import type { ITilesetData, ITileAnimation } from '@esengine/tilemap';
|
||||
import '../../styles/TileSetSelectorPanel.css';
|
||||
|
||||
interface TilesetOption {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface TileSetSelectorPanelProps {
|
||||
tilesets: TilesetOption[];
|
||||
activeTilesetIndex: number;
|
||||
activeTileset?: ITilesetData;
|
||||
tilesetImage?: HTMLImageElement | null;
|
||||
onTilesetChange: (index: number) => void;
|
||||
onAddTileset: () => void;
|
||||
onTileAnimationChange?: (tileId: number, animation: ITileAnimation | null) => void;
|
||||
}
|
||||
|
||||
export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
||||
tilesets,
|
||||
activeTilesetIndex,
|
||||
activeTileset,
|
||||
tilesetImage,
|
||||
onTilesetChange,
|
||||
onAddTileset,
|
||||
onTileAnimationChange
|
||||
}) => {
|
||||
const {
|
||||
currentTool,
|
||||
setCurrentTool,
|
||||
tilesetImageUrl,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
tilesetColumns,
|
||||
tilesetRows,
|
||||
selectedTiles,
|
||||
editingCollision,
|
||||
setEditingCollision
|
||||
} = useTilemapEditorStore();
|
||||
|
||||
const [showTilesetDropdown, setShowTilesetDropdown] = useState(false);
|
||||
const [previewZoom, _setPreviewZoom] = useState(1);
|
||||
const [editingAnimationTileId, setEditingAnimationTileId] = useState<number | null>(null);
|
||||
|
||||
// Get animated tile IDs from tileset
|
||||
const animatedTileIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
if (activeTileset?.tiles) {
|
||||
for (const tile of activeTileset.tiles) {
|
||||
if (tile.animation && tile.animation.frames.length > 0) {
|
||||
ids.add(tile.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}, [activeTileset]);
|
||||
|
||||
// Get current animation for editing tile
|
||||
const editingTileAnimation = useMemo(() => {
|
||||
if (editingAnimationTileId === null || !activeTileset?.tiles) return null;
|
||||
const tile = activeTileset.tiles.find(t => t.id === editingAnimationTileId);
|
||||
return tile?.animation ?? null;
|
||||
}, [editingAnimationTileId, activeTileset]);
|
||||
|
||||
const handleEditAnimation = useCallback((tileId: number) => {
|
||||
setEditingAnimationTileId(tileId);
|
||||
}, []);
|
||||
|
||||
const handleAnimationChange = useCallback((animation: ITileAnimation | null) => {
|
||||
if (editingAnimationTileId !== null && onTileAnimationChange) {
|
||||
onTileAnimationChange(editingAnimationTileId, animation);
|
||||
}
|
||||
}, [editingAnimationTileId, onTileAnimationChange]);
|
||||
|
||||
const handleCloseAnimationEditor = useCallback(() => {
|
||||
setEditingAnimationTileId(null);
|
||||
}, []);
|
||||
|
||||
const handleToolChange = useCallback((tool: TilemapToolType) => {
|
||||
setCurrentTool(tool);
|
||||
}, [setCurrentTool]);
|
||||
|
||||
const { setShowCollision } = useTilemapEditorStore();
|
||||
|
||||
const handleToggleCollisionMode = useCallback((enabled: boolean) => {
|
||||
setEditingCollision(enabled);
|
||||
// 启用碰撞编辑时自动显示碰撞
|
||||
if (enabled) {
|
||||
setShowCollision(true);
|
||||
}
|
||||
}, [setEditingCollision, setShowCollision]);
|
||||
|
||||
const activeTilesetOption = tilesets[activeTilesetIndex];
|
||||
|
||||
return (
|
||||
<div className="tileset-selector-panel">
|
||||
{/* Mode toggle */}
|
||||
<div className="tileset-mode-toggle">
|
||||
<button
|
||||
className={`mode-toggle-btn ${!editingCollision ? 'active' : ''}`}
|
||||
onClick={() => handleToggleCollisionMode(false)}
|
||||
title="瓦片编辑模式"
|
||||
>
|
||||
<Paintbrush size={14} />
|
||||
<span>瓦片</span>
|
||||
</button>
|
||||
<button
|
||||
className={`mode-toggle-btn ${editingCollision ? 'active' : ''}`}
|
||||
onClick={() => handleToggleCollisionMode(true)}
|
||||
title="碰撞编辑模式"
|
||||
>
|
||||
<Box size={14} />
|
||||
<span>碰撞</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tool buttons */}
|
||||
<div className="tileset-tools">
|
||||
<button
|
||||
className={`tileset-tool-btn ${currentTool === 'brush' ? 'active' : ''}`}
|
||||
onClick={() => handleToolChange('brush')}
|
||||
title={editingCollision ? "绘制碰撞" : "绘制瓦片"}
|
||||
>
|
||||
<Paintbrush size={24} />
|
||||
<span>绘制</span>
|
||||
</button>
|
||||
<button
|
||||
className={`tileset-tool-btn ${currentTool === 'eraser' ? 'active' : ''}`}
|
||||
onClick={() => handleToolChange('eraser')}
|
||||
title={editingCollision ? "擦除碰撞" : "擦除瓦片"}
|
||||
>
|
||||
<Eraser size={24} />
|
||||
<span>橡皮擦</span>
|
||||
</button>
|
||||
<button
|
||||
className={`tileset-tool-btn ${currentTool === 'fill' ? 'active' : ''}`}
|
||||
onClick={() => handleToolChange('fill')}
|
||||
title={editingCollision ? "填充碰撞" : "填充瓦片"}
|
||||
>
|
||||
<PaintBucket size={24} />
|
||||
<span>填充</span>
|
||||
</button>
|
||||
<button
|
||||
className={`tileset-tool-btn ${currentTool === 'rectangle' ? 'active' : ''}`}
|
||||
onClick={() => handleToolChange('rectangle')}
|
||||
title={editingCollision ? "矩形碰撞" : "矩形绘制"}
|
||||
>
|
||||
<Square size={24} />
|
||||
<span>矩形</span>
|
||||
</button>
|
||||
<button
|
||||
className={`tileset-tool-btn ${currentTool === 'select' ? 'active' : ''}`}
|
||||
onClick={() => handleToolChange('select')}
|
||||
title="选择区域"
|
||||
>
|
||||
<BoxSelect size={24} />
|
||||
<span>选择</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active Tile Set selector */}
|
||||
<div className="tileset-selector">
|
||||
<div className="tileset-selector-row">
|
||||
<label>活跃瓦片集</label>
|
||||
<div className="tileset-selector-actions">
|
||||
<button className="tileset-action-btn" title="显示网格">
|
||||
<Grid3x3 size={14} />
|
||||
</button>
|
||||
<button className="tileset-action-btn" title="搜索">
|
||||
<Search size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tileset-dropdown-wrapper">
|
||||
<button
|
||||
className="tileset-dropdown-btn"
|
||||
onClick={() => setShowTilesetDropdown(!showTilesetDropdown)}
|
||||
>
|
||||
<span>{activeTilesetOption?.name || '(无)'}</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{showTilesetDropdown && (
|
||||
<div className="tileset-dropdown-menu">
|
||||
<button
|
||||
className={`tileset-dropdown-item ${activeTilesetIndex === -1 ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
onTilesetChange(-1);
|
||||
setShowTilesetDropdown(false);
|
||||
}}
|
||||
>
|
||||
(无)
|
||||
</button>
|
||||
{tilesets.map((tileset, index) => (
|
||||
<button
|
||||
key={tileset.path}
|
||||
className={`tileset-dropdown-item ${index === activeTilesetIndex ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
onTilesetChange(index);
|
||||
setShowTilesetDropdown(false);
|
||||
}}
|
||||
>
|
||||
{tileset.name}
|
||||
</button>
|
||||
))}
|
||||
<div className="tileset-dropdown-divider" />
|
||||
<button
|
||||
className="tileset-dropdown-item add-new"
|
||||
onClick={() => {
|
||||
onAddTileset();
|
||||
setShowTilesetDropdown(false);
|
||||
}}
|
||||
>
|
||||
+ 添加瓦片集...
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zoom and title header */}
|
||||
<div className="tileset-header">
|
||||
<span className="tileset-zoom-label">缩放{previewZoom}:1</span>
|
||||
<span className="tileset-title">瓦片集选择器</span>
|
||||
</div>
|
||||
|
||||
{/* Tile preview area */}
|
||||
<div className="tileset-preview-area">
|
||||
{editingCollision ? (
|
||||
<div className="collision-mode-hint">
|
||||
<Box size={32} />
|
||||
<span className="collision-mode-title">碰撞编辑模式</span>
|
||||
<span className="collision-mode-desc">使用画笔绘制碰撞区域</span>
|
||||
<span className="collision-mode-desc">使用橡皮擦清除碰撞</span>
|
||||
</div>
|
||||
) : tilesetImageUrl ? (
|
||||
<TilesetPreview
|
||||
imageUrl={tilesetImageUrl}
|
||||
tileWidth={tileWidth}
|
||||
tileHeight={tileHeight}
|
||||
columns={tilesetColumns}
|
||||
rows={tilesetRows}
|
||||
tileset={activeTileset}
|
||||
animatedTileIds={animatedTileIds}
|
||||
onEditAnimation={handleEditAnimation}
|
||||
/>
|
||||
) : (
|
||||
<div className="tileset-empty-hint">
|
||||
<button className="tileset-select-btn" onClick={onAddTileset}>
|
||||
选择瓦片集
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection info */}
|
||||
{selectedTiles && (
|
||||
<div className="tileset-selection-info">
|
||||
已选择: {selectedTiles.width}×{selectedTiles.height}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Animation Editor */}
|
||||
{editingAnimationTileId !== null && activeTileset && tilesetImage && (
|
||||
<TileAnimationEditor
|
||||
tileId={editingAnimationTileId}
|
||||
tileset={activeTileset}
|
||||
tilesetImage={tilesetImage}
|
||||
animation={editingTileAnimation}
|
||||
onAnimationChange={handleAnimationChange}
|
||||
onClose={handleCloseAnimationEditor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TileSetSelectorPanel;
|
||||
@@ -0,0 +1,732 @@
|
||||
/**
|
||||
* Tilemap Details Panel - Right panel with grouped properties
|
||||
* Tilemap 详情面板 - 右侧分组属性面板
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Copy,
|
||||
X,
|
||||
Search,
|
||||
Settings,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileBox
|
||||
} from 'lucide-react';
|
||||
import { useTilemapEditorStore, type LayerState } from '../../stores/TilemapEditorStore';
|
||||
import type { TilemapComponent } from '@esengine/tilemap';
|
||||
import '../../styles/TilemapDetailsPanel.css';
|
||||
|
||||
interface TilemapDetailsPanelProps {
|
||||
tilemap: TilemapComponent | null;
|
||||
onAddLayer: () => void;
|
||||
onRemoveLayer: (index: number) => void;
|
||||
onMoveLayer: (from: number, to: number) => void;
|
||||
onDuplicateLayer: (index: number) => void;
|
||||
onTilemapChange: () => void;
|
||||
onOpenAssetPicker: () => void;
|
||||
/** Callback to open material picker for a specific layer */
|
||||
onSelectLayerMaterial?: (layerIndex: number) => void;
|
||||
}
|
||||
|
||||
// Collapsible section component
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Section: React.FC<SectionProps> = ({ title, defaultOpen = true, children }) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="details-section">
|
||||
<div
|
||||
className="details-section-header"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="details-section-content">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Property row component
|
||||
interface PropertyRowProps {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
indent?: boolean;
|
||||
}
|
||||
|
||||
const PropertyRow: React.FC<PropertyRowProps> = ({ label, children, indent }) => (
|
||||
<div className={`property-row ${indent ? 'indented' : ''}`}>
|
||||
<label>{label}</label>
|
||||
<div className="property-value">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Toggle property - unified style matching PropertyInspector
|
||||
interface TogglePropertyProps {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
indent?: boolean;
|
||||
}
|
||||
|
||||
const ToggleProperty: React.FC<TogglePropertyProps> = ({ label, checked, onChange, indent }) => (
|
||||
<div className={`property-row toggle-row ${indent ? 'indented' : ''}`}>
|
||||
<label>{label}</label>
|
||||
<button
|
||||
className={`property-toggle ${checked ? 'property-toggle-on' : 'property-toggle-off'}`}
|
||||
onClick={() => onChange(!checked)}
|
||||
type="button"
|
||||
>
|
||||
<span className="property-toggle-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Number input property
|
||||
interface NumberPropertyProps {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
const NumberProperty: React.FC<NumberPropertyProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
step = 1
|
||||
}) => (
|
||||
<PropertyRow label={label}>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
// Slider property for opacity etc.
|
||||
interface SliderPropertyProps {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
const SliderProperty: React.FC<SliderPropertyProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
max = 1,
|
||||
step = 0.01
|
||||
}) => (
|
||||
<PropertyRow label={label}>
|
||||
<div className="slider-wrapper">
|
||||
<input
|
||||
type="range"
|
||||
className="property-slider"
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
/>
|
||||
<span className="slider-value">{Math.round(value * 100)}%</span>
|
||||
</div>
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
// Color property - unified style matching PropertyInspector
|
||||
interface ColorPropertyProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
showReset?: boolean;
|
||||
}
|
||||
|
||||
const ColorProperty: React.FC<ColorPropertyProps> = ({ label, value, onChange }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handlePreviewClick = () => {
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<PropertyRow label={label}>
|
||||
<div className="property-color-wrapper">
|
||||
<div
|
||||
className="property-color-preview"
|
||||
style={{ backgroundColor: value }}
|
||||
onClick={handlePreviewClick}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="color"
|
||||
className="property-input-color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="property-input property-input-color-text"
|
||||
value={value.toUpperCase()}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (/^#[0-9A-Fa-f]{0,6}$/.test(val)) {
|
||||
onChange(val);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value;
|
||||
if (!/^#[0-9A-Fa-f]{6}$/.test(val)) {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PropertyRow>
|
||||
);
|
||||
};
|
||||
|
||||
// Material field - AssetField-like style for material selection
|
||||
interface MaterialFieldProps {
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
onSelect: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
const MaterialField: React.FC<MaterialFieldProps> = ({ label, value, onSelect, onClear }) => {
|
||||
const getFileName = (path: string) => {
|
||||
const parts = path.split(/[\\/]/);
|
||||
return parts[parts.length - 1].replace('.mat', '').replace('.json', '');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="material-field">
|
||||
<label className="material-field__label">{label}</label>
|
||||
<div className="material-field__content">
|
||||
{/* Thumbnail */}
|
||||
<div className="material-field__thumbnail">
|
||||
<FileBox size={18} className="material-field__thumbnail-icon" />
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="material-field__right">
|
||||
{/* Dropdown */}
|
||||
<div
|
||||
className={`material-field__dropdown ${value ? 'has-value' : ''}`}
|
||||
onClick={onSelect}
|
||||
title={value || '点击选择材质'}
|
||||
>
|
||||
<span className="material-field__value">
|
||||
{value ? getFileName(value) : '默认材质'}
|
||||
</span>
|
||||
<ChevronDown size={12} className="material-field__dropdown-arrow" />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="material-field__actions">
|
||||
{value && (
|
||||
<>
|
||||
<button
|
||||
className="material-field__btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(value);
|
||||
}}
|
||||
title="复制路径"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="material-field__btn material-field__btn--clear"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClear();
|
||||
}}
|
||||
title="清除"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
||||
tilemap,
|
||||
onAddLayer,
|
||||
onRemoveLayer,
|
||||
onMoveLayer,
|
||||
onDuplicateLayer,
|
||||
onTilemapChange,
|
||||
onOpenAssetPicker,
|
||||
onSelectLayerMaterial
|
||||
}) => {
|
||||
const {
|
||||
layers,
|
||||
currentLayer,
|
||||
setCurrentLayer,
|
||||
toggleLayerVisibility,
|
||||
setLayerOpacity,
|
||||
setLayerColor,
|
||||
setLayerHiddenInGame,
|
||||
renameLayer,
|
||||
showCollision,
|
||||
setShowCollision
|
||||
} = useTilemapEditorStore();
|
||||
|
||||
// Layer name editing state
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
|
||||
// Layer properties state - synced with store
|
||||
const selectedLayer = layers[currentLayer];
|
||||
const [layerCollides, setLayerCollides] = useState(true);
|
||||
const [overrideCollisionThickness, setOverrideCollisionThickness] = useState(false);
|
||||
const [overrideCollisionOffset, setOverrideCollisionOffset] = useState(false);
|
||||
const [collisionThickness, setCollisionThickness] = useState(50.0);
|
||||
const [collisionOffset, setCollisionOffset] = useState(0.0);
|
||||
|
||||
// hiddenInEditor is derived from layer visibility (inverse relationship)
|
||||
const hiddenInEditor = selectedLayer ? !selectedLayer.visible : false;
|
||||
|
||||
const handleHiddenInEditorChange = useCallback((hidden: boolean) => {
|
||||
if (currentLayer >= 0 && currentLayer < layers.length) {
|
||||
toggleLayerVisibility(currentLayer);
|
||||
// Also update tilemap component
|
||||
if (tilemap && tilemap.layers[currentLayer]) {
|
||||
tilemap.layers[currentLayer].visible = !hidden;
|
||||
tilemap.renderDirty = true;
|
||||
onTilemapChange();
|
||||
}
|
||||
}
|
||||
}, [currentLayer, layers.length, toggleLayerVisibility, tilemap, onTilemapChange]);
|
||||
|
||||
// Handle eye icon click in layer list
|
||||
const handleLayerVisibilityToggle = useCallback((index: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toggleLayerVisibility(index);
|
||||
// Also update tilemap component
|
||||
if (tilemap && tilemap.layers[index]) {
|
||||
tilemap.layers[index].visible = !tilemap.layers[index].visible;
|
||||
tilemap.renderDirty = true;
|
||||
onTilemapChange();
|
||||
}
|
||||
}, [toggleLayerVisibility, tilemap, onTilemapChange]);
|
||||
|
||||
// Handle layer opacity change
|
||||
const handleLayerOpacityChange = useCallback((opacity: number) => {
|
||||
if (currentLayer >= 0 && currentLayer < layers.length) {
|
||||
setLayerOpacity(currentLayer, opacity);
|
||||
// Also update tilemap component
|
||||
if (tilemap && tilemap.layers[currentLayer]) {
|
||||
tilemap.layers[currentLayer].opacity = opacity;
|
||||
tilemap.renderDirty = true;
|
||||
onTilemapChange();
|
||||
}
|
||||
}
|
||||
}, [currentLayer, layers.length, setLayerOpacity, tilemap, onTilemapChange]);
|
||||
|
||||
// Handle layer name editing
|
||||
const handleStartEditName = useCallback(() => {
|
||||
if (selectedLayer) {
|
||||
setEditingName(selectedLayer.name);
|
||||
setIsEditingName(true);
|
||||
}
|
||||
}, [selectedLayer]);
|
||||
|
||||
const handleFinishEditName = useCallback(() => {
|
||||
if (isEditingName && editingName.trim()) {
|
||||
renameLayer(currentLayer, editingName.trim());
|
||||
// Also update tilemap component
|
||||
if (tilemap && tilemap.layers[currentLayer]) {
|
||||
tilemap.renameLayer(currentLayer, editingName.trim());
|
||||
onTilemapChange();
|
||||
}
|
||||
}
|
||||
setIsEditingName(false);
|
||||
}, [isEditingName, editingName, currentLayer, renameLayer, tilemap, onTilemapChange]);
|
||||
|
||||
const handleNameKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleFinishEditName();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsEditingName(false);
|
||||
}
|
||||
}, [handleFinishEditName]);
|
||||
|
||||
// Handle layer color change
|
||||
const handleLayerColorChange = useCallback((color: string) => {
|
||||
if (currentLayer >= 0 && currentLayer < layers.length) {
|
||||
setLayerColor(currentLayer, color);
|
||||
if (tilemap) {
|
||||
tilemap.setLayerColor(currentLayer, color);
|
||||
tilemap.renderDirty = true;
|
||||
onTilemapChange();
|
||||
}
|
||||
}
|
||||
}, [currentLayer, layers.length, setLayerColor, tilemap, onTilemapChange]);
|
||||
|
||||
// Handle layer hidden in game change
|
||||
const handleHiddenInGameChange = useCallback((hidden: boolean) => {
|
||||
if (currentLayer >= 0 && currentLayer < layers.length) {
|
||||
setLayerHiddenInGame(currentLayer, hidden);
|
||||
if (tilemap) {
|
||||
tilemap.setLayerHiddenInGame(currentLayer, hidden);
|
||||
onTilemapChange();
|
||||
}
|
||||
}
|
||||
}, [currentLayer, layers.length, setLayerHiddenInGame, tilemap, onTilemapChange]);
|
||||
|
||||
// Colors for grid (editor settings, not layer properties)
|
||||
const [tileGridColor, setTileGridColor] = useState('#333333');
|
||||
const [multiTileGridColor, setMultiTileGridColor] = useState('#ff0000');
|
||||
const [_layerGridColor, _setLayerGridColor] = useState('#00ff00');
|
||||
|
||||
const handleLayerSelect = useCallback((index: number) => {
|
||||
setCurrentLayer(index);
|
||||
}, [setCurrentLayer]);
|
||||
|
||||
const handleMapWidthChange = useCallback((value: number) => {
|
||||
if (tilemap && value > 0) {
|
||||
tilemap.resize(value, tilemap.height, 'bottom-left');
|
||||
onTilemapChange();
|
||||
}
|
||||
}, [tilemap, onTilemapChange]);
|
||||
|
||||
const handleMapHeightChange = useCallback((value: number) => {
|
||||
if (tilemap && value > 0) {
|
||||
tilemap.resize(tilemap.width, value, 'bottom-left');
|
||||
onTilemapChange();
|
||||
}
|
||||
}, [tilemap, onTilemapChange]);
|
||||
|
||||
const handleTileWidthChange = useCallback((value: number) => {
|
||||
if (tilemap && value > 0) {
|
||||
tilemap.tileWidth = value;
|
||||
onTilemapChange();
|
||||
}
|
||||
}, [tilemap, onTilemapChange]);
|
||||
|
||||
const handleTileHeightChange = useCallback((value: number) => {
|
||||
if (tilemap && value > 0) {
|
||||
tilemap.tileHeight = value;
|
||||
onTilemapChange();
|
||||
}
|
||||
}, [tilemap, onTilemapChange]);
|
||||
|
||||
if (!tilemap) {
|
||||
return (
|
||||
<div className="tilemap-details-panel">
|
||||
<div className="details-empty">
|
||||
未选择瓦片地图
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tilemap-details-panel">
|
||||
<div className="details-header">
|
||||
<span className="details-header-title">细节</span>
|
||||
<div className="details-header-actions">
|
||||
<div className="details-search-inline">
|
||||
<Search size={12} />
|
||||
<input type="text" placeholder="搜索" />
|
||||
</div>
|
||||
<button className="details-settings-btn" title="设置">
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="details-content">
|
||||
{/* Tile Map Section */}
|
||||
<Section title="瓦片地图">
|
||||
<div className="property-row">
|
||||
<label>瓦片层列表</label>
|
||||
<span className="layer-count-badge">图层 {currentLayer + 1}</span>
|
||||
</div>
|
||||
|
||||
{/* Tile Layers List */}
|
||||
<div className="layer-list-container">
|
||||
{layers.map((layer, index) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`layer-list-item ${index === currentLayer ? 'selected' : ''}`}
|
||||
onClick={() => handleLayerSelect(index)}
|
||||
>
|
||||
<button
|
||||
className={`layer-visibility-btn ${layer.visible ? '' : 'hidden'}`}
|
||||
onClick={(e) => handleLayerVisibilityToggle(index, e)}
|
||||
title={layer.visible ? '隐藏图层' : '显示图层'}
|
||||
>
|
||||
{layer.visible ? <Eye size={12} /> : <EyeOff size={12} />}
|
||||
</button>
|
||||
<span className="layer-icon">◆</span>
|
||||
<span>{layer.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Layer action buttons */}
|
||||
<div className="layer-actions-row">
|
||||
<button
|
||||
className="layer-action-btn add"
|
||||
onClick={onAddLayer}
|
||||
title="添加图层"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="layer-action-btn"
|
||||
onClick={() => currentLayer > 0 && onMoveLayer(currentLayer, currentLayer - 1)}
|
||||
disabled={currentLayer <= 0}
|
||||
title="上移"
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="layer-action-btn"
|
||||
onClick={() => currentLayer < layers.length - 1 && onMoveLayer(currentLayer, currentLayer + 1)}
|
||||
disabled={currentLayer >= layers.length - 1}
|
||||
title="下移"
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="layer-action-btn"
|
||||
onClick={() => onDuplicateLayer(currentLayer)}
|
||||
title="复制图层"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="layer-action-btn danger"
|
||||
onClick={() => layers.length > 1 && onRemoveLayer(currentLayer)}
|
||||
disabled={layers.length <= 1}
|
||||
title="删除图层"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Selected Layer Section */}
|
||||
<Section title="选定层">
|
||||
<PropertyRow label="名称">
|
||||
{isEditingName ? (
|
||||
<input
|
||||
type="text"
|
||||
className="layer-name-input"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onBlur={handleFinishEditName}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="selected-layer-name editable"
|
||||
onDoubleClick={handleStartEditName}
|
||||
title="双击编辑名称"
|
||||
>
|
||||
{selectedLayer?.name || '图层 1'}
|
||||
</span>
|
||||
)}
|
||||
</PropertyRow>
|
||||
<ToggleProperty
|
||||
label="编辑器中隐藏"
|
||||
checked={hiddenInEditor}
|
||||
onChange={handleHiddenInEditorChange}
|
||||
/>
|
||||
<ToggleProperty
|
||||
label="游戏中隐藏"
|
||||
checked={selectedLayer?.hiddenInGame ?? false}
|
||||
onChange={handleHiddenInGameChange}
|
||||
/>
|
||||
<SliderProperty
|
||||
label="图层透明度"
|
||||
value={selectedLayer?.opacity ?? 1}
|
||||
onChange={handleLayerOpacityChange}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
<ToggleProperty
|
||||
label="图层碰撞"
|
||||
checked={layerCollides}
|
||||
onChange={setLayerCollides}
|
||||
/>
|
||||
<ToggleProperty
|
||||
label="重载碰撞厚度"
|
||||
checked={overrideCollisionThickness}
|
||||
onChange={setOverrideCollisionThickness}
|
||||
indent
|
||||
/>
|
||||
<ToggleProperty
|
||||
label="重载碰撞偏移"
|
||||
checked={overrideCollisionOffset}
|
||||
onChange={setOverrideCollisionOffset}
|
||||
indent
|
||||
/>
|
||||
{overrideCollisionThickness && (
|
||||
<NumberProperty
|
||||
label="碰撞厚度重载"
|
||||
value={collisionThickness}
|
||||
onChange={setCollisionThickness}
|
||||
/>
|
||||
)}
|
||||
{overrideCollisionOffset && (
|
||||
<NumberProperty
|
||||
label="碰撞偏移重载"
|
||||
value={collisionOffset}
|
||||
onChange={setCollisionOffset}
|
||||
/>
|
||||
)}
|
||||
<ColorProperty
|
||||
label="图层颜色"
|
||||
value={selectedLayer?.color ?? '#ffffff'}
|
||||
onChange={handleLayerColorChange}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Setup Section */}
|
||||
<Section title="配置">
|
||||
<NumberProperty
|
||||
label="地图宽度"
|
||||
value={tilemap.width}
|
||||
onChange={handleMapWidthChange}
|
||||
min={1}
|
||||
/>
|
||||
<NumberProperty
|
||||
label="地图高度"
|
||||
value={tilemap.height}
|
||||
onChange={handleMapHeightChange}
|
||||
min={1}
|
||||
/>
|
||||
<NumberProperty
|
||||
label="瓦片宽度"
|
||||
value={tilemap.tileWidth}
|
||||
onChange={handleTileWidthChange}
|
||||
min={1}
|
||||
/>
|
||||
<NumberProperty
|
||||
label="瓦片高度"
|
||||
value={tilemap.tileHeight}
|
||||
onChange={handleTileHeightChange}
|
||||
min={1}
|
||||
/>
|
||||
<NumberProperty
|
||||
label="逻辑单位像素"
|
||||
value={1.0}
|
||||
onChange={() => {}}
|
||||
step={0.1}
|
||||
/>
|
||||
<NumberProperty
|
||||
label="逐图层分隔"
|
||||
value={4.0}
|
||||
onChange={() => {}}
|
||||
step={0.1}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Material Section */}
|
||||
<Section title="图层材质" defaultOpen={true}>
|
||||
<div className="material-section-content">
|
||||
<MaterialField
|
||||
label={`${selectedLayer?.name || '图层'} 材质`}
|
||||
value={tilemap.getLayerMaterial(currentLayer)}
|
||||
onSelect={() => onSelectLayerMaterial?.(currentLayer)}
|
||||
onClear={() => {
|
||||
tilemap.setLayerMaterial(currentLayer, '');
|
||||
onTilemapChange();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Advanced Section */}
|
||||
<Section title="高级" defaultOpen={false}>
|
||||
<PropertyRow label="投射模式">
|
||||
<select defaultValue="orthogonal">
|
||||
<option value="orthogonal">正交</option>
|
||||
<option value="isometric">等轴测</option>
|
||||
<option value="hexagonal">六方</option>
|
||||
</select>
|
||||
</PropertyRow>
|
||||
<NumberProperty
|
||||
label="六方格边长度"
|
||||
value={0}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<ColorProperty
|
||||
label="背景颜色"
|
||||
value={tileGridColor}
|
||||
onChange={setTileGridColor}
|
||||
showReset
|
||||
/>
|
||||
<ColorProperty
|
||||
label="瓦片网格颜色"
|
||||
value={tileGridColor}
|
||||
onChange={setTileGridColor}
|
||||
showReset
|
||||
/>
|
||||
<ColorProperty
|
||||
label="多瓦片网格颜色"
|
||||
value={multiTileGridColor}
|
||||
onChange={setMultiTileGridColor}
|
||||
showReset
|
||||
/>
|
||||
<NumberProperty
|
||||
label="多瓦片网格宽度"
|
||||
value={0}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Collision Section */}
|
||||
<Section title="碰撞" defaultOpen={false}>
|
||||
<ToggleProperty
|
||||
label="显示碰撞"
|
||||
checked={showCollision}
|
||||
onChange={setShowCollision}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TilemapDetailsPanel;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Tileset Panel - Display tileset for selection
|
||||
*/
|
||||
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { TilemapComponent, type ITilesetData } from '@esengine/tilemap';
|
||||
import { useTilemapEditorStore } from '../../stores/TilemapEditorStore';
|
||||
import { TilesetPreview } from '../TilesetPreview';
|
||||
import '../../styles/TilemapEditor.css';
|
||||
|
||||
// Helper to convert file path to URL
|
||||
function convertFileSrc(path: string): string {
|
||||
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('asset://')) {
|
||||
return path;
|
||||
}
|
||||
return `asset://localhost/${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
interface TilesetPanelProps {
|
||||
projectPath?: string | null;
|
||||
}
|
||||
|
||||
export const TilesetPanel: React.FC<TilesetPanelProps> = () => {
|
||||
const {
|
||||
entityId,
|
||||
tilesetImageUrl,
|
||||
tilesetColumns,
|
||||
tilesetRows,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
selectedTiles,
|
||||
setTileset
|
||||
} = useTilemapEditorStore();
|
||||
|
||||
// Load tileset from component
|
||||
const loadTilesetFromComponent = useCallback(() => {
|
||||
if (!entityId) return;
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
const foundEntity = scene.findEntityById(parseInt(entityId, 10));
|
||||
if (!foundEntity) return;
|
||||
|
||||
const tilemapComp = foundEntity.getComponent(TilemapComponent);
|
||||
if (!tilemapComp) return;
|
||||
|
||||
// Get tileset source from first tileset
|
||||
const tilesetRef = tilemapComp.tilesets[0];
|
||||
if (!tilesetRef) return;
|
||||
|
||||
const tilesetPath = tilesetRef.source;
|
||||
const imageUrl = convertFileSrc(tilesetPath);
|
||||
const currentState = useTilemapEditorStore.getState();
|
||||
|
||||
// Check if URL or tile dimensions changed
|
||||
const urlChanged = imageUrl !== currentState.tilesetImageUrl;
|
||||
const dimensionsChanged =
|
||||
tilemapComp.tileWidth !== currentState.tileWidth ||
|
||||
tilemapComp.tileHeight !== currentState.tileHeight;
|
||||
|
||||
if (!urlChanged && !dimensionsChanged) return;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const columns = Math.floor(img.width / tilemapComp.tileWidth);
|
||||
const rows = Math.floor(img.height / tilemapComp.tileHeight);
|
||||
|
||||
// Create tileset data and set it
|
||||
const tilesetData: ITilesetData = {
|
||||
name: 'tileset',
|
||||
version: 1,
|
||||
image: tilesetPath,
|
||||
imageWidth: img.width,
|
||||
imageHeight: img.height,
|
||||
tileWidth: tilemapComp.tileWidth,
|
||||
tileHeight: tilemapComp.tileHeight,
|
||||
tileCount: columns * rows,
|
||||
columns,
|
||||
rows
|
||||
};
|
||||
tilemapComp.setTilesetData(0, tilesetData);
|
||||
setTileset(imageUrl, columns, rows, tilemapComp.tileWidth, tilemapComp.tileHeight);
|
||||
};
|
||||
img.src = imageUrl;
|
||||
}, [entityId, setTileset]);
|
||||
|
||||
// Load tileset when entityId is set but tilesetImageUrl is not yet loaded
|
||||
useEffect(() => {
|
||||
if (!entityId || tilesetImageUrl) return;
|
||||
loadTilesetFromComponent();
|
||||
}, [entityId, tilesetImageUrl, loadTilesetFromComponent]);
|
||||
|
||||
// Listen for scene modifications to reload tileset when property changes
|
||||
useEffect(() => {
|
||||
if (!entityId) return;
|
||||
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (!messageHub) return;
|
||||
|
||||
const unsubscribe = messageHub.subscribe('scene:modified', () => {
|
||||
loadTilesetFromComponent();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [entityId, loadTilesetFromComponent]);
|
||||
|
||||
if (!tilesetImageUrl) {
|
||||
return (
|
||||
<div className="tileset-panel">
|
||||
<div className="tileset-panel-header">
|
||||
<h3>Tileset</h3>
|
||||
</div>
|
||||
<div className="tileset-empty">
|
||||
<p>
|
||||
No tileset loaded.
|
||||
<br />
|
||||
Select a TilemapComponent to edit.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tileset-panel">
|
||||
<div className="tileset-panel-header">
|
||||
<h3>Tileset</h3>
|
||||
</div>
|
||||
<div className="tileset-canvas-container">
|
||||
<TilesetPreview
|
||||
imageUrl={tilesetImageUrl}
|
||||
tileWidth={tileWidth}
|
||||
tileHeight={tileHeight}
|
||||
columns={tilesetColumns}
|
||||
rows={tilesetRows}
|
||||
/>
|
||||
</div>
|
||||
{selectedTiles && (
|
||||
<div className="tilemap-info-bar">
|
||||
<span>
|
||||
Selected: {selectedTiles.width}×{selectedTiles.height}
|
||||
</span>
|
||||
<span>Tile: {selectedTiles.tiles[0]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Tilemap Gizmo Implementation
|
||||
* Tilemap Gizmo 实现
|
||||
*
|
||||
* Registers gizmo provider for TilemapComponent using the GizmoRegistry.
|
||||
* Rendered via Rust WebGL engine for optimal performance.
|
||||
* 使用 GizmoRegistry 为 TilemapComponent 注册 gizmo 提供者。
|
||||
* 通过 Rust WebGL 引擎渲染以获得最佳性能。
|
||||
*/
|
||||
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import type { IGizmoRenderData, IRectGizmoData, IGridGizmoData, GizmoColor } from '@esengine/editor-core';
|
||||
import { GizmoColors, GizmoRegistry } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { TilemapComponent } from '@esengine/tilemap';
|
||||
import { TilemapCollider2DComponent, TilemapColliderMode } from '@esengine/tilemap';
|
||||
|
||||
/**
|
||||
* Tilemap Collider Gizmo 颜色配置
|
||||
*/
|
||||
const TilemapColliderGizmoColors = {
|
||||
/** 碰撞体边框 - 青色 */
|
||||
collider: { r: 0.0, g: 0.8, b: 0.8, a: 0.8 } as GizmoColor,
|
||||
/** 碰撞体填充 - 半透明青色 */
|
||||
colliderFill: { r: 0.0, g: 0.8, b: 0.8, a: 0.2 } as GizmoColor,
|
||||
/** 选中时的碰撞体 - 亮青色 */
|
||||
colliderSelected: { r: 0.0, g: 1.0, b: 1.0, a: 1.0 } as GizmoColor,
|
||||
/** 触发器 - 橙色 */
|
||||
trigger: { r: 1.0, g: 0.6, b: 0.0, a: 0.8 } as GizmoColor,
|
||||
};
|
||||
|
||||
/**
|
||||
* Gizmo provider function for TilemapComponent.
|
||||
* TilemapComponent 的 gizmo 提供者函数。
|
||||
*
|
||||
* Provides gizmo data including:
|
||||
* - Outer boundary rectangle
|
||||
* - Tile grid overlay (when selected)
|
||||
*
|
||||
* 提供的 gizmo 数据包括:
|
||||
* - 外部边界矩形
|
||||
* - 瓦片网格覆盖层(选中时)
|
||||
*/
|
||||
function tilemapGizmoProvider(
|
||||
tilemap: TilemapComponent,
|
||||
entity: Entity,
|
||||
isSelected: boolean
|
||||
): IGizmoRenderData[] {
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
|
||||
if (!transform) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const gizmos: IGizmoRenderData[] = [];
|
||||
|
||||
// Calculate tilemap world bounds
|
||||
// 计算 tilemap 世界边界
|
||||
const width = tilemap.width * tilemap.tileWidth * transform.scale.x;
|
||||
const height = tilemap.height * tilemap.tileHeight * transform.scale.y;
|
||||
|
||||
// Get rotation (handle both number and Vector3)
|
||||
// 获取旋转(处理数字和 Vector3 两种情况)
|
||||
const rotation = typeof transform.rotation === 'number'
|
||||
? transform.rotation
|
||||
: transform.rotation.z;
|
||||
|
||||
// Center position (tilemap origin is at bottom-left)
|
||||
// 中心位置(tilemap 原点在左下角)
|
||||
const centerX = transform.position.x + width / 2;
|
||||
const centerY = transform.position.y + height / 2;
|
||||
|
||||
// Use predefined colors based on selection state
|
||||
// 根据选择状态使用预定义颜色
|
||||
const boundaryColor: GizmoColor = isSelected
|
||||
? GizmoColors.selected
|
||||
: GizmoColors.unselected;
|
||||
|
||||
// Outer boundary rectangle
|
||||
// 外部边界矩形
|
||||
const boundaryGizmo: IRectGizmoData = {
|
||||
type: 'rect',
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
width,
|
||||
height,
|
||||
rotation,
|
||||
originX: 0.5,
|
||||
originY: 0.5,
|
||||
color: boundaryColor,
|
||||
showHandles: false
|
||||
};
|
||||
gizmos.push(boundaryGizmo);
|
||||
|
||||
// Grid overlay (only when selected for performance)
|
||||
// 网格覆盖层(仅选中时显示以保证性能)
|
||||
if (isSelected) {
|
||||
const gridColor: GizmoColor = { ...GizmoColors.grid, a: 0.3 };
|
||||
|
||||
const gridGizmo: IGridGizmoData = {
|
||||
type: 'grid',
|
||||
x: transform.position.x,
|
||||
y: transform.position.y,
|
||||
width,
|
||||
height,
|
||||
cols: tilemap.width,
|
||||
rows: tilemap.height,
|
||||
color: gridColor
|
||||
};
|
||||
gizmos.push(gridGizmo);
|
||||
}
|
||||
|
||||
return gizmos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gizmo provider function for TilemapCollider2DComponent.
|
||||
* TilemapCollider2DComponent 的 gizmo 提供者函数。
|
||||
*
|
||||
* Provides gizmo data for collision visualization:
|
||||
* - Shows collision rectangles (per-tile or merged)
|
||||
* - Different colors for trigger vs collider
|
||||
*
|
||||
* 提供碰撞可视化的 gizmo 数据:
|
||||
* - 显示碰撞矩形(每格或合并)
|
||||
* - 触发器和碰撞体使用不同颜色
|
||||
*/
|
||||
function tilemapCollider2DGizmoProvider(
|
||||
collider: TilemapCollider2DComponent,
|
||||
entity: Entity,
|
||||
isSelected: boolean
|
||||
): IGizmoRenderData[] {
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
const tilemap = entity.getComponent(TilemapComponent);
|
||||
|
||||
if (!transform || !tilemap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const gizmos: IGizmoRenderData[] = [];
|
||||
|
||||
// 获取碰撞颜色
|
||||
const color = isSelected
|
||||
? TilemapColliderGizmoColors.colliderSelected
|
||||
: (collider.isTrigger ? TilemapColliderGizmoColors.trigger : TilemapColliderGizmoColors.collider);
|
||||
|
||||
// 获取实体位置偏移
|
||||
const offsetX = transform.position.x;
|
||||
const offsetY = transform.position.y;
|
||||
const scaleX = transform.scale.x;
|
||||
const scaleY = transform.scale.y;
|
||||
|
||||
// 获取旋转
|
||||
const rotation = typeof transform.rotation === 'number'
|
||||
? transform.rotation
|
||||
: transform.rotation.z;
|
||||
|
||||
// 计算地图总高度(像素),用于 Y 轴翻转
|
||||
// Calculate total map height (pixels) for Y-axis flip
|
||||
const mapPixelHeight = tilemap.height * tilemap.tileHeight;
|
||||
|
||||
// 如果已有碰撞矩形缓存,直接使用
|
||||
if (collider._collisionRects.length > 0) {
|
||||
// 使用已生成的碰撞矩形
|
||||
for (const rect of collider._collisionRects) {
|
||||
// Y 轴翻转:rect.y 是从顶部计算的,需要翻转到底部
|
||||
// Y-axis flip: rect.y is calculated from top, needs flip to bottom
|
||||
const flippedY = mapPixelHeight - rect.y - rect.height;
|
||||
const worldX = offsetX + (rect.x + rect.width / 2) * scaleX;
|
||||
const worldY = offsetY + (flippedY + rect.height / 2) * scaleY;
|
||||
const worldWidth = rect.width * scaleX;
|
||||
const worldHeight = rect.height * scaleY;
|
||||
|
||||
const rectGizmo: IRectGizmoData = {
|
||||
type: 'rect',
|
||||
x: worldX,
|
||||
y: worldY,
|
||||
width: worldWidth,
|
||||
height: worldHeight,
|
||||
rotation,
|
||||
originX: 0.5,
|
||||
originY: 0.5,
|
||||
color,
|
||||
showHandles: false
|
||||
};
|
||||
gizmos.push(rectGizmo);
|
||||
}
|
||||
} else {
|
||||
// 如果没有缓存,根据模式生成预览
|
||||
const collisionData = tilemap.collisionData;
|
||||
const width = tilemap.width;
|
||||
const height = tilemap.height;
|
||||
const tileWidth = tilemap.tileWidth;
|
||||
const tileHeight = tilemap.tileHeight;
|
||||
|
||||
if (collider.colliderMode === TilemapColliderMode.PerTile) {
|
||||
// PerTile 模式:每个碰撞格子单独显示
|
||||
for (let row = 0; row < height; row++) {
|
||||
for (let col = 0; col < width; col++) {
|
||||
if (collisionData[row * width + col] > 0) {
|
||||
// Y 轴翻转:数据存储 row 0 在顶部,渲染时 Y-up 需要翻转
|
||||
// Y-axis flip: data stores row 0 at top, rendering needs Y-up flip
|
||||
const flippedRow = height - 1 - row;
|
||||
const worldX = offsetX + (col * tileWidth + tileWidth / 2) * scaleX;
|
||||
const worldY = offsetY + (flippedRow * tileHeight + tileHeight / 2) * scaleY;
|
||||
const worldWidth = tileWidth * scaleX;
|
||||
const worldHeight = tileHeight * scaleY;
|
||||
|
||||
const rectGizmo: IRectGizmoData = {
|
||||
type: 'rect',
|
||||
x: worldX,
|
||||
y: worldY,
|
||||
width: worldWidth,
|
||||
height: worldHeight,
|
||||
rotation,
|
||||
originX: 0.5,
|
||||
originY: 0.5,
|
||||
color,
|
||||
showHandles: false
|
||||
};
|
||||
gizmos.push(rectGizmo);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Merged 模式:使用贪心算法合并相邻格子
|
||||
const rects = generateMergedRects(collisionData, width, height, tileWidth, tileHeight);
|
||||
|
||||
for (const rect of rects) {
|
||||
// Y 轴翻转:rect.y 是从顶部计算的,需要翻转到底部
|
||||
// Y-axis flip: rect.y is calculated from top, needs flip to bottom
|
||||
const flippedY = mapPixelHeight - rect.y - rect.height;
|
||||
const worldX = offsetX + (rect.x + rect.width / 2) * scaleX;
|
||||
const worldY = offsetY + (flippedY + rect.height / 2) * scaleY;
|
||||
const worldWidth = rect.width * scaleX;
|
||||
const worldHeight = rect.height * scaleY;
|
||||
|
||||
const rectGizmo: IRectGizmoData = {
|
||||
type: 'rect',
|
||||
x: worldX,
|
||||
y: worldY,
|
||||
width: worldWidth,
|
||||
height: worldHeight,
|
||||
rotation,
|
||||
originX: 0.5,
|
||||
originY: 0.5,
|
||||
color,
|
||||
showHandles: false
|
||||
};
|
||||
gizmos.push(rectGizmo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gizmos;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成合并的碰撞矩形(贪心算法)
|
||||
* 用于 Gizmo 预览,与 TilemapCollider2DComponent 中的算法相同
|
||||
*/
|
||||
function generateMergedRects(
|
||||
collisionData: Uint32Array,
|
||||
width: number,
|
||||
height: number,
|
||||
tileWidth: number,
|
||||
tileHeight: number
|
||||
): Array<{ x: number; y: number; width: number; height: number }> {
|
||||
if (collisionData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const processed = new Array(width * height).fill(false);
|
||||
const rects: Array<{ x: number; y: number; width: number; height: number }> = [];
|
||||
|
||||
for (let row = 0; row < height; row++) {
|
||||
for (let col = 0; col < width; col++) {
|
||||
const index = row * width + col;
|
||||
|
||||
if (processed[index] || collisionData[index] === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 找到水平方向最大范围
|
||||
let endCol = col;
|
||||
while (
|
||||
endCol < width &&
|
||||
collisionData[row * width + endCol] > 0 &&
|
||||
!processed[row * width + endCol]
|
||||
) {
|
||||
endCol++;
|
||||
}
|
||||
const rectWidth = endCol - col;
|
||||
|
||||
// 找到垂直方向最大范围
|
||||
let endRow = row;
|
||||
let canExtend = true;
|
||||
while (canExtend && endRow < height) {
|
||||
for (let c = col; c < endCol; c++) {
|
||||
const idx = endRow * width + c;
|
||||
if (collisionData[idx] === 0 || processed[idx]) {
|
||||
canExtend = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (canExtend) {
|
||||
endRow++;
|
||||
}
|
||||
}
|
||||
const rectHeight = endRow - row;
|
||||
|
||||
// 标记所有包含的格子为已处理
|
||||
for (let r = row; r < endRow; r++) {
|
||||
for (let c = col; c < endCol; c++) {
|
||||
processed[r * width + c] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加合并后的矩形
|
||||
rects.push({
|
||||
x: col * tileWidth,
|
||||
y: row * tileHeight,
|
||||
width: rectWidth * tileWidth,
|
||||
height: rectHeight * tileHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register gizmo provider for TilemapComponent.
|
||||
* 为 TilemapComponent 注册 gizmo 提供者。
|
||||
*
|
||||
* Uses the GizmoRegistry pattern for clean separation between
|
||||
* game components and editor functionality.
|
||||
* 使用 GizmoRegistry 模式实现游戏组件和编辑器功能的清晰分离。
|
||||
*/
|
||||
export function registerTilemapGizmo(): void {
|
||||
GizmoRegistry.register(TilemapComponent, tilemapGizmoProvider);
|
||||
GizmoRegistry.register(TilemapCollider2DComponent, tilemapCollider2DGizmoProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister gizmo provider for TilemapComponent.
|
||||
* 取消注册 TilemapComponent 的 gizmo 提供者。
|
||||
*/
|
||||
export function unregisterTilemapGizmo(): void {
|
||||
GizmoRegistry.unregister(TilemapComponent);
|
||||
GizmoRegistry.unregister(TilemapCollider2DComponent);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Tilemap Editor Locale Hook
|
||||
* 瓦片地图编辑器语言钩子
|
||||
*
|
||||
* Uses the unified plugin i18n infrastructure from editor-runtime.
|
||||
* 使用 editor-runtime 的统一插件国际化基础设施。
|
||||
*/
|
||||
import {
|
||||
createPluginLocale,
|
||||
createPluginTranslator,
|
||||
getCurrentLocale
|
||||
} from '@esengine/editor-runtime';
|
||||
import { en, zh, es } from '../locales';
|
||||
import type { Locale, TranslationParams } from '@esengine/editor-core';
|
||||
|
||||
// Create translations bundle
|
||||
// 创建翻译包
|
||||
const translations = { en, zh, es };
|
||||
|
||||
/**
|
||||
* Hook for accessing tilemap editor translations
|
||||
* 访问瓦片地图编辑器翻译的 Hook
|
||||
*
|
||||
* Uses the unified createPluginLocale factory from editor-runtime.
|
||||
* 使用 editor-runtime 的统一 createPluginLocale 工厂。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { t, locale } = useTilemapLocale();
|
||||
* return <button title={t('toolbar.save')}>{t('toolbar.saveButton')}</button>;
|
||||
* ```
|
||||
*/
|
||||
export const useTilemapLocale = createPluginLocale(translations);
|
||||
|
||||
// Create non-React translator using the unified infrastructure
|
||||
// 使用统一基础设施创建非 React 翻译器
|
||||
const tilemapTranslator = createPluginTranslator(translations);
|
||||
|
||||
/**
|
||||
* Non-React translation function for tilemap editor
|
||||
* 瓦片地图编辑器的非 React 翻译函数
|
||||
*
|
||||
* Use this in services, utilities, and other non-React contexts.
|
||||
* 在服务、工具类和其他非 React 上下文中使用。
|
||||
*
|
||||
* @param key - Translation key | 翻译键
|
||||
* @param locale - Optional locale, defaults to current locale | 可选语言,默认使用当前语言
|
||||
* @param params - Optional interpolation parameters | 可选插值参数
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // With explicit locale
|
||||
* translateTilemap('errors.notFound', 'zh');
|
||||
*
|
||||
* // With current locale (auto-detected)
|
||||
* translateTilemap('toolbar.save');
|
||||
*
|
||||
* // With parameters
|
||||
* translateTilemap('layers.layerCount', undefined, { count: 5 });
|
||||
* ```
|
||||
*/
|
||||
export function translateTilemap(
|
||||
key: string,
|
||||
locale?: Locale,
|
||||
params?: TranslationParams
|
||||
): string {
|
||||
const targetLocale = locale || getCurrentLocale();
|
||||
return tilemapTranslator(key, targetLocale, params);
|
||||
}
|
||||
|
||||
// Re-export for external use
|
||||
// 重新导出供外部使用
|
||||
export { getCurrentLocale } from '@esengine/editor-runtime';
|
||||
392
packages/editor/plugins/tilemap-editor/src/index.ts
Normal file
392
packages/editor/plugins/tilemap-editor/src/index.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Tilemap 编辑器模块入口
|
||||
* Tilemap Editor Module Entry
|
||||
*/
|
||||
|
||||
import type { ServiceContainer, Entity } from '@esengine/ecs-framework';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IEditorModuleLoader,
|
||||
PanelDescriptor,
|
||||
EntityCreationTemplate,
|
||||
ComponentAction,
|
||||
ComponentInspectorProviderDef,
|
||||
GizmoProviderRegistration,
|
||||
FileActionHandler,
|
||||
FileCreationTemplate
|
||||
} from '@esengine/editor-core';
|
||||
import {
|
||||
PanelPosition,
|
||||
InspectorRegistry,
|
||||
EntityStoreService,
|
||||
MessageHub,
|
||||
EditorComponentRegistry,
|
||||
IDialogService,
|
||||
IFileSystemService,
|
||||
FileActionRegistry
|
||||
} from '@esengine/editor-core';
|
||||
import type { IDialog, IFileSystem } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
|
||||
// Runtime imports from @esengine/tilemap
|
||||
import { TilemapComponent, TilemapCollider2DComponent, TilemapRuntimeModule } from '@esengine/tilemap';
|
||||
import type { IEditorPlugin, ModuleManifest } from '@esengine/editor-core';
|
||||
import { TilemapEditorPanel } from './components/panels/TilemapEditorPanel';
|
||||
import { TilemapInspectorProvider } from './providers/TilemapInspectorProvider';
|
||||
import { registerTilemapGizmo } from './gizmos/TilemapGizmo';
|
||||
import { useTilemapEditorStore } from './stores/TilemapEditorStore';
|
||||
|
||||
// 导入编辑器 CSS 样式(会被 vite 自动处理并注入到 DOM)
|
||||
// Import editor CSS styles (automatically handled and injected by vite)
|
||||
import './styles/TilemapEditor.css';
|
||||
|
||||
// Re-exports
|
||||
export { TilemapEditorPanel } from './components/panels/TilemapEditorPanel';
|
||||
export { TilesetPanel } from './components/panels/TilesetPanel';
|
||||
export { TilemapCanvas } from './components/TilemapCanvas';
|
||||
export { TilemapViewport } from './components/TilemapViewport';
|
||||
export { TilesetPreview } from './components/TilesetPreview';
|
||||
export { useTilemapEditorStore } from './stores/TilemapEditorStore';
|
||||
export type { TilemapEditorState, TilemapToolType, TileSelection } from './stores/TilemapEditorStore';
|
||||
export type { ITilemapTool, ToolContext } from './tools/ITilemapTool';
|
||||
export { BrushTool } from './tools/BrushTool';
|
||||
export { EraserTool } from './tools/EraserTool';
|
||||
export { FillTool } from './tools/FillTool';
|
||||
export { RectangleTool } from './tools/RectangleTool';
|
||||
export { SelectTool } from './tools/SelectTool';
|
||||
export { TilemapInspectorProvider } from './providers/TilemapInspectorProvider';
|
||||
export { TileAnimationEditor } from './components/panels/TileAnimationEditor';
|
||||
|
||||
/**
|
||||
* Tilemap 编辑器模块
|
||||
* Tilemap Editor Module
|
||||
*/
|
||||
export class TilemapEditorModule implements IEditorModuleLoader {
|
||||
private unsubscribers: Array<() => void> = [];
|
||||
|
||||
async install(services: ServiceContainer): Promise<void> {
|
||||
// 注册检视器提供者 | Register inspector provider
|
||||
const inspectorRegistry = services.resolve(InspectorRegistry);
|
||||
if (inspectorRegistry) {
|
||||
inspectorRegistry.register(new TilemapInspectorProvider());
|
||||
}
|
||||
|
||||
// 注册组件到编辑器组件注册表 | Register to editor component registry
|
||||
const componentRegistry = services.resolve(EditorComponentRegistry);
|
||||
if (componentRegistry) {
|
||||
componentRegistry.register({
|
||||
name: 'Tilemap',
|
||||
type: TilemapComponent,
|
||||
category: 'components.category.tilemap',
|
||||
description: 'Tilemap component for tile-based levels',
|
||||
icon: 'Grid3X3'
|
||||
});
|
||||
|
||||
componentRegistry.register({
|
||||
name: 'TilemapCollider2D',
|
||||
type: TilemapCollider2DComponent,
|
||||
category: 'components.category.physics',
|
||||
description: 'Generates physics colliders from tilemap collision data',
|
||||
icon: 'Shield'
|
||||
});
|
||||
}
|
||||
|
||||
// 订阅 tilemap:create-asset 消息 | Subscribe to tilemap:create-asset message
|
||||
const messageHub = services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
const unsubscribe = messageHub.subscribe('tilemap:create-asset', async (payload: {
|
||||
entityId?: string;
|
||||
onChange?: (value: string | null) => void;
|
||||
}) => {
|
||||
await this.handleCreateTilemapAsset(services, payload);
|
||||
});
|
||||
this.unsubscribers.push(unsubscribe);
|
||||
}
|
||||
|
||||
// 注册资产创建消息映射 | Register asset creation message mappings
|
||||
const fileActionRegistry = services.resolve(FileActionRegistry);
|
||||
if (fileActionRegistry) {
|
||||
fileActionRegistry.registerAssetCreationMapping({
|
||||
extension: '.tilemap',
|
||||
createMessage: 'tilemap:create-asset'
|
||||
});
|
||||
fileActionRegistry.registerAssetCreationMapping({
|
||||
extension: '.tileset',
|
||||
createMessage: 'tileset:create-asset'
|
||||
});
|
||||
}
|
||||
|
||||
// 注册 Tilemap Gizmo | Register Tilemap gizmo
|
||||
registerTilemapGizmo();
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
// 清理订阅 | Clean up subscriptions
|
||||
this.unsubscribers.forEach(unsub => unsub());
|
||||
this.unsubscribers = [];
|
||||
}
|
||||
|
||||
getPanels(): PanelDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'tilemap-editor',
|
||||
title: 'Tilemap Editor',
|
||||
position: PanelPosition.Center,
|
||||
closable: true,
|
||||
component: TilemapEditorPanel,
|
||||
isDynamic: true
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
getInspectorProviders(): ComponentInspectorProviderDef[] {
|
||||
return [
|
||||
{
|
||||
componentType: 'Tilemap',
|
||||
priority: 100,
|
||||
render: (component, entity, onChange) => {
|
||||
const provider = new TilemapInspectorProvider();
|
||||
return provider.render(
|
||||
{ entityId: String(entity.id), component },
|
||||
{ target: component, onChange }
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getEntityCreationTemplates(): EntityCreationTemplate[] {
|
||||
return [
|
||||
{
|
||||
id: 'create-tilemap-entity',
|
||||
label: '创建 Tilemap',
|
||||
icon: 'Grid3X3',
|
||||
category: 'rendering',
|
||||
order: 100,
|
||||
create: (): number => {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('Scene not available');
|
||||
}
|
||||
|
||||
const entityStore = Core.services.resolve(EntityStoreService);
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
|
||||
if (!entityStore || !messageHub) {
|
||||
throw new Error('EntityStoreService or MessageHub not available');
|
||||
}
|
||||
|
||||
const tilemapCount = entityStore.getAllEntities()
|
||||
.filter((e: Entity) => e.name.startsWith('Tilemap ')).length;
|
||||
const entityName = `Tilemap ${tilemapCount + 1}`;
|
||||
|
||||
const entity = scene.createEntity(entityName);
|
||||
entity.addComponent(new TransformComponent());
|
||||
|
||||
const tilemapComponent = new TilemapComponent();
|
||||
tilemapComponent.tileWidth = 16;
|
||||
tilemapComponent.tileHeight = 16;
|
||||
tilemapComponent.initializeEmpty(20, 15);
|
||||
entity.addComponent(tilemapComponent);
|
||||
|
||||
entityStore.addEntity(entity);
|
||||
messageHub.publish('entity:added', { entity });
|
||||
messageHub.publish('scene:modified', {});
|
||||
entityStore.selectEntity(entity);
|
||||
|
||||
return entity.id;
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getComponentActions(): ComponentAction[] {
|
||||
// 移除编辑按钮,改为双击 tilemap 文件打开编辑器
|
||||
return [];
|
||||
}
|
||||
|
||||
getFileActionHandlers(): FileActionHandler[] {
|
||||
return [
|
||||
{
|
||||
extensions: ['tilemap', 'json'],
|
||||
onDoubleClick: (filePath: string) => {
|
||||
// 只处理 .tilemap 和 .tilemap.json 文件
|
||||
const lowerPath = filePath.toLowerCase();
|
||||
if (!lowerPath.endsWith('.tilemap') && !lowerPath.endsWith('.tilemap.json')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先设置待打开的文件路径到 store
|
||||
useTilemapEditorStore.getState().setPendingFilePath(filePath);
|
||||
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
// 打开 tilemap 编辑器面板(面板挂载后会从 store 读取 pendingFilePath)
|
||||
messageHub.publish('dynamic-panel:open', {
|
||||
panelId: 'tilemap-editor',
|
||||
title: `Tilemap Editor - ${filePath.split(/[\\/]/).pop()}`
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
extensions: ['tileset', 'json'],
|
||||
onDoubleClick: (filePath: string) => {
|
||||
// 只处理 .tileset 和 .tileset.json 文件
|
||||
const lowerPath = filePath.toLowerCase();
|
||||
if (!lowerPath.endsWith('.tileset') && !lowerPath.endsWith('.tileset.json')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
// 发送消息打开 tileset 预览
|
||||
messageHub.publish('tileset:open-file', { filePath });
|
||||
messageHub.publish('dynamic-panel:open', {
|
||||
panelId: 'tilemap-editor',
|
||||
title: `Tileset - ${filePath.split(/[\\/]/).pop()}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getFileCreationTemplates(): FileCreationTemplate[] {
|
||||
return [
|
||||
{
|
||||
id: 'create-tilemap',
|
||||
label: 'Tilemap',
|
||||
extension: 'tilemap',
|
||||
icon: 'Grid3X3',
|
||||
category: 'tilemap',
|
||||
getContent: (_fileName: string) => {
|
||||
const defaultTilemapData = {
|
||||
width: 20,
|
||||
height: 15,
|
||||
tileWidth: 16,
|
||||
tileHeight: 16,
|
||||
layers: [
|
||||
{
|
||||
name: 'Layer 1',
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
data: new Array(20 * 15).fill(0)
|
||||
}
|
||||
],
|
||||
tilesets: []
|
||||
};
|
||||
return JSON.stringify(defaultTilemapData, null, 2);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'create-tileset',
|
||||
label: 'Tileset',
|
||||
extension: 'tileset',
|
||||
icon: 'LayoutGrid',
|
||||
category: 'tilemap',
|
||||
getContent: (_fileName: string) => {
|
||||
const defaultTilesetData = {
|
||||
name: 'New Tileset',
|
||||
tileWidth: 16,
|
||||
tileHeight: 16,
|
||||
spacing: 0,
|
||||
margin: 0,
|
||||
imageSource: '',
|
||||
tiles: []
|
||||
};
|
||||
return JSON.stringify(defaultTilesetData, null, 2);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private async handleCreateTilemapAsset(
|
||||
_services: ServiceContainer,
|
||||
payload: { entityId?: string; onChange?: (value: string | null) => void }
|
||||
): Promise<void> {
|
||||
const dialog = Core.services.tryResolve(IDialogService) as IDialog | null;
|
||||
const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
|
||||
if (!dialog || !fileSystem) {
|
||||
console.error('[TilemapEditorModule] Dialog or FileSystem service not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = await dialog.saveDialog({
|
||||
title: '创建 Tilemap 资产',
|
||||
filters: [{ name: 'Tilemap', extensions: ['tilemap'] }],
|
||||
defaultPath: 'new-tilemap.tilemap'
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultTilemapData = {
|
||||
width: 20,
|
||||
height: 15,
|
||||
tileWidth: 16,
|
||||
tileHeight: 16,
|
||||
layers: [
|
||||
{
|
||||
name: 'Layer 1',
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
data: new Array(20 * 15).fill(0)
|
||||
}
|
||||
],
|
||||
tilesets: []
|
||||
};
|
||||
|
||||
await fileSystem.writeFile(filePath, JSON.stringify(defaultTilemapData, null, 2));
|
||||
|
||||
if (payload.onChange) {
|
||||
payload.onChange(filePath);
|
||||
}
|
||||
|
||||
if (messageHub && payload.entityId) {
|
||||
useTilemapEditorStore.getState().setEntityId(payload.entityId);
|
||||
messageHub.publish('dynamic-panel:open', { panelId: 'tilemap-editor', title: 'Tilemap Editor' });
|
||||
messageHub.publish('dynamic-panel:open', { panelId: 'tileset-panel', title: 'Tileset' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const tilemapEditorModule = new TilemapEditorModule();
|
||||
|
||||
/**
|
||||
* Tilemap 插件清单
|
||||
* Tilemap Plugin Manifest
|
||||
*/
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/tilemap',
|
||||
name: '@esengine/tilemap',
|
||||
displayName: 'Tilemap',
|
||||
version: '1.0.0',
|
||||
description: 'Tilemap system with Tiled editor support',
|
||||
category: 'Rendering',
|
||||
isCore: false,
|
||||
defaultEnabled: false,
|
||||
isEngineModule: true,
|
||||
canContainContent: true,
|
||||
dependencies: ['engine-core'],
|
||||
exports: {
|
||||
components: ['TilemapComponent', 'TilemapCollider2DComponent'],
|
||||
systems: ['TilemapRenderingSystem'],
|
||||
loaders: ['TilemapLoader', 'TilesetLoader']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 完整的 Tilemap 插件(运行时 + 编辑器)
|
||||
* Complete Tilemap Plugin (runtime + editor)
|
||||
*/
|
||||
export const TilemapPlugin: IEditorPlugin = {
|
||||
manifest,
|
||||
runtimeModule: new TilemapRuntimeModule(),
|
||||
editorModule: tilemapEditorModule
|
||||
};
|
||||
|
||||
export default tilemapEditorModule;
|
||||
181
packages/editor/plugins/tilemap-editor/src/locales/en.ts
Normal file
181
packages/editor/plugins/tilemap-editor/src/locales/en.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* English translations for Tilemap Editor
|
||||
* 瓦片地图编辑器英文翻译
|
||||
*/
|
||||
export const en = {
|
||||
// ========================================
|
||||
// Panel
|
||||
// ========================================
|
||||
panel: {
|
||||
title: 'Tilemap Editor',
|
||||
noTilemapSelected: 'No tilemap selected',
|
||||
details: 'Details',
|
||||
search: 'Search'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Tools
|
||||
// ========================================
|
||||
tools: {
|
||||
tileMode: 'Tile editing mode',
|
||||
collisionMode: 'Collision editing mode',
|
||||
tile: 'Tile',
|
||||
collision: 'Collision',
|
||||
draw: 'Draw',
|
||||
drawTile: 'Draw tiles',
|
||||
drawCollision: 'Draw collision',
|
||||
eraser: 'Eraser',
|
||||
eraseTile: 'Erase tiles',
|
||||
eraseCollision: 'Erase collision',
|
||||
fill: 'Fill',
|
||||
fillTile: 'Fill tiles',
|
||||
fillCollision: 'Fill collision',
|
||||
rectangle: 'Rectangle',
|
||||
rectangleTile: 'Rectangle draw',
|
||||
rectangleCollision: 'Rectangle collision',
|
||||
select: 'Select',
|
||||
selectRegion: 'Select region'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Tileset
|
||||
// ========================================
|
||||
tileset: {
|
||||
activeTileset: 'Active Tileset',
|
||||
showGrid: 'Show grid',
|
||||
search: 'Search',
|
||||
none: '(None)',
|
||||
addTileset: '+ Add Tileset...',
|
||||
zoom: 'Zoom {{zoom}}:1',
|
||||
selector: 'Tileset Selector',
|
||||
selectTileset: 'Select Tileset',
|
||||
selected: 'Selected: {{width}}×{{height}}'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Collision Mode
|
||||
// ========================================
|
||||
collisionMode: {
|
||||
title: 'Collision Edit Mode',
|
||||
drawHint: 'Use brush to draw collision areas',
|
||||
eraseHint: 'Use eraser to clear collision'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Layers
|
||||
// ========================================
|
||||
layers: {
|
||||
title: 'Layers',
|
||||
addLayer: 'Add Layer',
|
||||
layerCount: 'Layers ({{count}})',
|
||||
layer: 'Layer',
|
||||
layerNumber: 'Layer {{number}}',
|
||||
editingCollision: 'Currently editing collision',
|
||||
drawingLayer: 'Currently drawing layer',
|
||||
moveUp: 'Move layer up',
|
||||
moveDown: 'Move layer down',
|
||||
delete: 'Delete layer',
|
||||
duplicate: 'Duplicate layer',
|
||||
hide: 'Hide layer',
|
||||
show: 'Show layer'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Layer Properties
|
||||
// ========================================
|
||||
layerProperties: {
|
||||
title: 'Selected Layer',
|
||||
name: 'Name',
|
||||
editName: 'Double-click to edit name',
|
||||
hideInEditor: 'Hide in editor',
|
||||
hideInGame: 'Hide in game',
|
||||
opacity: 'Layer opacity',
|
||||
collision: 'Layer collision',
|
||||
overrideThickness: 'Override collision thickness',
|
||||
overrideOffset: 'Override collision offset',
|
||||
thicknessOverride: 'Collision thickness override',
|
||||
offsetOverride: 'Collision offset override',
|
||||
color: 'Layer color',
|
||||
material: '{{name}} Material'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Configuration
|
||||
// ========================================
|
||||
config: {
|
||||
title: 'Configuration',
|
||||
mapWidth: 'Map width',
|
||||
mapHeight: 'Map height',
|
||||
tileWidth: 'Tile width',
|
||||
tileHeight: 'Tile height',
|
||||
pixelsPerUnit: 'Pixels per unit',
|
||||
separateByLayer: 'Separate by layer'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Layer Materials
|
||||
// ========================================
|
||||
materials: {
|
||||
title: 'Layer Materials',
|
||||
default: 'Default Material',
|
||||
selectMaterial: 'Click to select material',
|
||||
copyPath: 'Copy path',
|
||||
clear: 'Clear'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Advanced
|
||||
// ========================================
|
||||
advanced: {
|
||||
title: 'Advanced',
|
||||
projection: 'Projection mode',
|
||||
orthographic: 'Orthographic',
|
||||
isometric: 'Isometric',
|
||||
hexagonal: 'Hexagonal',
|
||||
hexSideLength: 'Hex side length',
|
||||
backgroundColor: 'Background color',
|
||||
tileGridColor: 'Tile grid color',
|
||||
multiTileGridColor: 'Multi-tile grid color',
|
||||
multiTileGridWidth: 'Multi-tile grid width'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Collision Settings
|
||||
// ========================================
|
||||
collisionSettings: {
|
||||
title: 'Collision',
|
||||
showCollision: 'Show collision'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Toolbar
|
||||
// ========================================
|
||||
toolbar: {
|
||||
toggleGrid: 'Toggle grid',
|
||||
showCollision: 'Show collision',
|
||||
save: 'Save (Ctrl+S)',
|
||||
saveButton: 'Save',
|
||||
zoomOut: 'Zoom out',
|
||||
zoomIn: 'Zoom in',
|
||||
resetView: 'Reset view',
|
||||
cells: ' cells'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Dialogs
|
||||
// ========================================
|
||||
dialogs: {
|
||||
selectTilesetImage: 'Select tileset image',
|
||||
selectLayerMaterial: 'Select layer material',
|
||||
searchAssets: 'Search assets...'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Animation Editor
|
||||
// ========================================
|
||||
animation: {
|
||||
frames: 'Animation Frames',
|
||||
deleteFrame: 'Delete frame',
|
||||
addFrameHint: 'Click a tile to add frame'
|
||||
}
|
||||
};
|
||||
181
packages/editor/plugins/tilemap-editor/src/locales/es.ts
Normal file
181
packages/editor/plugins/tilemap-editor/src/locales/es.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Spanish translations for Tilemap Editor
|
||||
* Traducciones en español del editor de mapas de tiles
|
||||
*/
|
||||
export const es = {
|
||||
// ========================================
|
||||
// Panel
|
||||
// ========================================
|
||||
panel: {
|
||||
title: 'Editor de Tilemap',
|
||||
noTilemapSelected: 'Ningún tilemap seleccionado',
|
||||
details: 'Detalles',
|
||||
search: 'Buscar'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Tools
|
||||
// ========================================
|
||||
tools: {
|
||||
tileMode: 'Modo de edición de tiles',
|
||||
collisionMode: 'Modo de edición de colisión',
|
||||
tile: 'Tile',
|
||||
collision: 'Colisión',
|
||||
draw: 'Dibujar',
|
||||
drawTile: 'Dibujar tiles',
|
||||
drawCollision: 'Dibujar colisión',
|
||||
eraser: 'Borrador',
|
||||
eraseTile: 'Borrar tiles',
|
||||
eraseCollision: 'Borrar colisión',
|
||||
fill: 'Rellenar',
|
||||
fillTile: 'Rellenar tiles',
|
||||
fillCollision: 'Rellenar colisión',
|
||||
rectangle: 'Rectángulo',
|
||||
rectangleTile: 'Dibujo rectangular',
|
||||
rectangleCollision: 'Colisión rectangular',
|
||||
select: 'Seleccionar',
|
||||
selectRegion: 'Seleccionar región'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Tileset
|
||||
// ========================================
|
||||
tileset: {
|
||||
activeTileset: 'Tileset activo',
|
||||
showGrid: 'Mostrar cuadrícula',
|
||||
search: 'Buscar',
|
||||
none: '(Ninguno)',
|
||||
addTileset: '+ Agregar Tileset...',
|
||||
zoom: 'Zoom {{zoom}}:1',
|
||||
selector: 'Selector de Tileset',
|
||||
selectTileset: 'Seleccionar Tileset',
|
||||
selected: 'Seleccionado: {{width}}×{{height}}'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Collision Mode
|
||||
// ========================================
|
||||
collisionMode: {
|
||||
title: 'Modo de edición de colisión',
|
||||
drawHint: 'Use el pincel para dibujar áreas de colisión',
|
||||
eraseHint: 'Use el borrador para eliminar colisión'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Layers
|
||||
// ========================================
|
||||
layers: {
|
||||
title: 'Capas',
|
||||
addLayer: 'Agregar capa',
|
||||
layerCount: 'Capas ({{count}})',
|
||||
layer: 'Capa',
|
||||
layerNumber: 'Capa {{number}}',
|
||||
editingCollision: 'Editando colisión actualmente',
|
||||
drawingLayer: 'Dibujando en capa actualmente',
|
||||
moveUp: 'Mover capa arriba',
|
||||
moveDown: 'Mover capa abajo',
|
||||
delete: 'Eliminar capa',
|
||||
duplicate: 'Duplicar capa',
|
||||
hide: 'Ocultar capa',
|
||||
show: 'Mostrar capa'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Layer Properties
|
||||
// ========================================
|
||||
layerProperties: {
|
||||
title: 'Capa seleccionada',
|
||||
name: 'Nombre',
|
||||
editName: 'Doble clic para editar nombre',
|
||||
hideInEditor: 'Ocultar en editor',
|
||||
hideInGame: 'Ocultar en juego',
|
||||
opacity: 'Opacidad de capa',
|
||||
collision: 'Colisión de capa',
|
||||
overrideThickness: 'Anular grosor de colisión',
|
||||
overrideOffset: 'Anular desplazamiento de colisión',
|
||||
thicknessOverride: 'Anulación de grosor de colisión',
|
||||
offsetOverride: 'Anulación de desplazamiento de colisión',
|
||||
color: 'Color de capa',
|
||||
material: 'Material {{name}}'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Configuration
|
||||
// ========================================
|
||||
config: {
|
||||
title: 'Configuración',
|
||||
mapWidth: 'Ancho del mapa',
|
||||
mapHeight: 'Alto del mapa',
|
||||
tileWidth: 'Ancho de tile',
|
||||
tileHeight: 'Alto de tile',
|
||||
pixelsPerUnit: 'Píxeles por unidad',
|
||||
separateByLayer: 'Separar por capa'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Layer Materials
|
||||
// ========================================
|
||||
materials: {
|
||||
title: 'Materiales de capa',
|
||||
default: 'Material predeterminado',
|
||||
selectMaterial: 'Clic para seleccionar material',
|
||||
copyPath: 'Copiar ruta',
|
||||
clear: 'Limpiar'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Advanced
|
||||
// ========================================
|
||||
advanced: {
|
||||
title: 'Avanzado',
|
||||
projection: 'Modo de proyección',
|
||||
orthographic: 'Ortográfico',
|
||||
isometric: 'Isométrico',
|
||||
hexagonal: 'Hexagonal',
|
||||
hexSideLength: 'Longitud del lado hexagonal',
|
||||
backgroundColor: 'Color de fondo',
|
||||
tileGridColor: 'Color de cuadrícula de tiles',
|
||||
multiTileGridColor: 'Color de cuadrícula multi-tile',
|
||||
multiTileGridWidth: 'Ancho de cuadrícula multi-tile'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Collision Settings
|
||||
// ========================================
|
||||
collisionSettings: {
|
||||
title: 'Colisión',
|
||||
showCollision: 'Mostrar colisión'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Toolbar
|
||||
// ========================================
|
||||
toolbar: {
|
||||
toggleGrid: 'Alternar cuadrícula',
|
||||
showCollision: 'Mostrar colisión',
|
||||
save: 'Guardar (Ctrl+S)',
|
||||
saveButton: 'Guardar',
|
||||
zoomOut: 'Alejar',
|
||||
zoomIn: 'Acercar',
|
||||
resetView: 'Restablecer vista',
|
||||
cells: ' celdas'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Dialogs
|
||||
// ========================================
|
||||
dialogs: {
|
||||
selectTilesetImage: 'Seleccionar imagen de tileset',
|
||||
selectLayerMaterial: 'Seleccionar material de capa',
|
||||
searchAssets: 'Buscar assets...'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Animation Editor
|
||||
// ========================================
|
||||
animation: {
|
||||
frames: 'Fotogramas de animación',
|
||||
deleteFrame: 'Eliminar fotograma',
|
||||
addFrameHint: 'Clic en un tile para agregar fotograma'
|
||||
}
|
||||
};
|
||||
11
packages/editor/plugins/tilemap-editor/src/locales/index.ts
Normal file
11
packages/editor/plugins/tilemap-editor/src/locales/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Tilemap Editor Locales
|
||||
* 瓦片地图编辑器语言包
|
||||
*
|
||||
* Export all locale translations for the tilemap editor plugin.
|
||||
* 导出瓦片地图编辑器插件的所有语言翻译。
|
||||
*/
|
||||
|
||||
export { en } from './en';
|
||||
export { zh } from './zh';
|
||||
export { es } from './es';
|
||||
181
packages/editor/plugins/tilemap-editor/src/locales/zh.ts
Normal file
181
packages/editor/plugins/tilemap-editor/src/locales/zh.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Chinese translations for Tilemap Editor
|
||||
* 瓦片地图编辑器中文翻译
|
||||
*/
|
||||
export const zh = {
|
||||
// ========================================
|
||||
// Panel
|
||||
// ========================================
|
||||
panel: {
|
||||
title: '瓦片地图编辑器',
|
||||
noTilemapSelected: '未选择瓦片地图',
|
||||
details: '细节',
|
||||
search: '搜索'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Tools
|
||||
// ========================================
|
||||
tools: {
|
||||
tileMode: '瓦片编辑模式',
|
||||
collisionMode: '碰撞编辑模式',
|
||||
tile: '瓦片',
|
||||
collision: '碰撞',
|
||||
draw: '绘制',
|
||||
drawTile: '绘制瓦片',
|
||||
drawCollision: '绘制碰撞',
|
||||
eraser: '橡皮擦',
|
||||
eraseTile: '擦除瓦片',
|
||||
eraseCollision: '擦除碰撞',
|
||||
fill: '填充',
|
||||
fillTile: '填充瓦片',
|
||||
fillCollision: '填充碰撞',
|
||||
rectangle: '矩形',
|
||||
rectangleTile: '矩形绘制',
|
||||
rectangleCollision: '矩形碰撞',
|
||||
select: '选择',
|
||||
selectRegion: '选择区域'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Tileset
|
||||
// ========================================
|
||||
tileset: {
|
||||
activeTileset: '活跃瓦片集',
|
||||
showGrid: '显示网格',
|
||||
search: '搜索',
|
||||
none: '(无)',
|
||||
addTileset: '+ 添加瓦片集...',
|
||||
zoom: '缩放 {{zoom}}:1',
|
||||
selector: '瓦片集选择器',
|
||||
selectTileset: '选择瓦片集',
|
||||
selected: '已选择: {{width}}×{{height}}'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Collision Mode
|
||||
// ========================================
|
||||
collisionMode: {
|
||||
title: '碰撞编辑模式',
|
||||
drawHint: '使用画笔绘制碰撞区域',
|
||||
eraseHint: '使用橡皮擦清除碰撞'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Layers
|
||||
// ========================================
|
||||
layers: {
|
||||
title: '图层',
|
||||
addLayer: '添加图层',
|
||||
layerCount: '图层 ({{count}})',
|
||||
layer: '图层',
|
||||
layerNumber: '图层 {{number}}',
|
||||
editingCollision: '当前编辑碰撞',
|
||||
drawingLayer: '当前绘制图层',
|
||||
moveUp: '上移图层',
|
||||
moveDown: '下移图层',
|
||||
delete: '删除图层',
|
||||
duplicate: '复制图层',
|
||||
hide: '隐藏图层',
|
||||
show: '显示图层'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Layer Properties
|
||||
// ========================================
|
||||
layerProperties: {
|
||||
title: '选定层',
|
||||
name: '名称',
|
||||
editName: '双击编辑名称',
|
||||
hideInEditor: '编辑器中隐藏',
|
||||
hideInGame: '游戏中隐藏',
|
||||
opacity: '图层透明度',
|
||||
collision: '图层碰撞',
|
||||
overrideThickness: '重载碰撞厚度',
|
||||
overrideOffset: '重载碰撞偏移',
|
||||
thicknessOverride: '碰撞厚度重载',
|
||||
offsetOverride: '碰撞偏移重载',
|
||||
color: '图层颜色',
|
||||
material: '{{name}} 材质'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Configuration
|
||||
// ========================================
|
||||
config: {
|
||||
title: '配置',
|
||||
mapWidth: '地图宽度',
|
||||
mapHeight: '地图高度',
|
||||
tileWidth: '瓦片宽度',
|
||||
tileHeight: '瓦片高度',
|
||||
pixelsPerUnit: '逻辑单位像素',
|
||||
separateByLayer: '逐图层分隔'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Layer Materials
|
||||
// ========================================
|
||||
materials: {
|
||||
title: '图层材质',
|
||||
default: '默认材质',
|
||||
selectMaterial: '点击选择材质',
|
||||
copyPath: '复制路径',
|
||||
clear: '清除'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Advanced
|
||||
// ========================================
|
||||
advanced: {
|
||||
title: '高级',
|
||||
projection: '投射模式',
|
||||
orthographic: '正交',
|
||||
isometric: '等轴测',
|
||||
hexagonal: '六方',
|
||||
hexSideLength: '六方格边长度',
|
||||
backgroundColor: '背景颜色',
|
||||
tileGridColor: '瓦片网格颜色',
|
||||
multiTileGridColor: '多瓦片网格颜色',
|
||||
multiTileGridWidth: '多瓦片网格宽度'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Collision Settings
|
||||
// ========================================
|
||||
collisionSettings: {
|
||||
title: '碰撞',
|
||||
showCollision: '显示碰撞'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Toolbar
|
||||
// ========================================
|
||||
toolbar: {
|
||||
toggleGrid: '切换网格',
|
||||
showCollision: '显示碰撞',
|
||||
save: '保存 (Ctrl+S)',
|
||||
saveButton: '保存',
|
||||
zoomOut: '缩小',
|
||||
zoomIn: '放大',
|
||||
resetView: '重置视图',
|
||||
cells: ' 格'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Dialogs
|
||||
// ========================================
|
||||
dialogs: {
|
||||
selectTilesetImage: '选择瓦片集图片',
|
||||
selectLayerMaterial: '选择图层材质',
|
||||
searchAssets: '搜索资产...'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Animation Editor
|
||||
// ========================================
|
||||
animation: {
|
||||
frames: '动画帧',
|
||||
deleteFrame: '删除帧',
|
||||
addFrameHint: '点击瓦片添加帧'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Tilemap Inspector Provider - Custom inspector for TilemapComponent
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Edit3 } from 'lucide-react';
|
||||
import type { IInspectorProvider, InspectorContext } from '@esengine/editor-core';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import type { TilemapComponent } from '@esengine/tilemap';
|
||||
|
||||
interface TilemapInspectorData {
|
||||
entityId: string;
|
||||
component: TilemapComponent;
|
||||
}
|
||||
|
||||
export class TilemapInspectorProvider implements IInspectorProvider<TilemapInspectorData> {
|
||||
readonly id = 'tilemap-component-inspector';
|
||||
readonly name = 'Tilemap Component Inspector';
|
||||
readonly priority = 100;
|
||||
|
||||
canHandle(target: unknown): target is TilemapInspectorData {
|
||||
if (typeof target !== 'object' || target === null) return false;
|
||||
const obj = target as Record<string, unknown>;
|
||||
return 'entityId' in obj && 'component' in obj &&
|
||||
obj.component !== null &&
|
||||
typeof obj.component === 'object' &&
|
||||
'width' in (obj.component as Record<string, unknown>) &&
|
||||
'height' in (obj.component as Record<string, unknown>) &&
|
||||
'tileWidth' in (obj.component as Record<string, unknown>);
|
||||
}
|
||||
|
||||
render(data: TilemapInspectorData, context: InspectorContext): React.ReactElement {
|
||||
const { entityId, component } = data;
|
||||
|
||||
const handleEditClick = () => {
|
||||
// Emit event to open tilemap editor
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
messageHub?.publish('tilemap:edit', { entityId });
|
||||
|
||||
// Open the tilemap editor panel
|
||||
messageHub?.publish('dynamic-panel:open', { panelId: 'tilemap-editor', title: 'Tilemap Editor' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Tilemap</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Size</label>
|
||||
<span>{component.width} × {component.height}</span>
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Tile Size</label>
|
||||
<span>{component.tileWidth} × {component.tileHeight}</span>
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Tileset</label>
|
||||
<span>{component.tilesets[0]?.source || 'None'}</span>
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Layers</label>
|
||||
<span>{component.layers.length}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<button
|
||||
onClick={handleEditClick}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 12px',
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--accent-color, #0078d4)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
Edit Tilemap
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Tilemap Editor State Store
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type TilemapToolType = 'brush' | 'eraser' | 'fill' | 'rectangle' | 'select';
|
||||
|
||||
export interface TileSelection {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
tiles: number[];
|
||||
}
|
||||
|
||||
export interface LayerState {
|
||||
id: string;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
opacity: number;
|
||||
color: string;
|
||||
hiddenInGame: boolean;
|
||||
}
|
||||
|
||||
export interface TilemapEditorState {
|
||||
// Current editing target
|
||||
entityId: string | null;
|
||||
|
||||
// Pending file to open (for file-based editing)
|
||||
pendingFilePath: string | null;
|
||||
// Current file being edited (for file-based editing)
|
||||
currentFilePath: string | null;
|
||||
|
||||
// Tileset
|
||||
tilesetImageUrl: string | null;
|
||||
tilesetColumns: number;
|
||||
tilesetRows: number;
|
||||
tileWidth: number;
|
||||
tileHeight: number;
|
||||
|
||||
// Selection
|
||||
selectedTiles: TileSelection | null;
|
||||
|
||||
// Tools
|
||||
currentTool: TilemapToolType;
|
||||
brushSize: number;
|
||||
|
||||
// View
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
showGrid: boolean;
|
||||
showCollision: boolean;
|
||||
|
||||
// Layers
|
||||
currentLayer: number;
|
||||
layers: LayerState[];
|
||||
editingCollision: boolean;
|
||||
|
||||
// History
|
||||
undoStack: Uint32Array[];
|
||||
redoStack: Uint32Array[];
|
||||
|
||||
// Actions
|
||||
setEntityId: (id: string | null) => void;
|
||||
setPendingFilePath: (path: string | null) => void;
|
||||
setCurrentFilePath: (path: string | null) => void;
|
||||
clearPendingFile: () => void;
|
||||
setTileset: (url: string | null, columns: number, rows: number, tileWidth: number, tileHeight: number) => void;
|
||||
setSelectedTiles: (selection: TileSelection | null) => void;
|
||||
setCurrentTool: (tool: TilemapToolType) => void;
|
||||
setBrushSize: (size: number) => void;
|
||||
setZoom: (zoom: number) => void;
|
||||
setPan: (x: number, y: number) => void;
|
||||
setShowGrid: (show: boolean) => void;
|
||||
setShowCollision: (show: boolean) => void;
|
||||
setCurrentLayer: (layer: number) => void;
|
||||
setEditingCollision: (editing: boolean) => void;
|
||||
pushUndo: (data: Uint32Array) => void;
|
||||
undo: () => Uint32Array | null;
|
||||
redo: () => Uint32Array | null;
|
||||
reset: () => void;
|
||||
|
||||
// Layer management
|
||||
setLayers: (layers: LayerState[]) => void;
|
||||
toggleLayerVisibility: (index: number) => void;
|
||||
toggleLayerLocked: (index: number) => void;
|
||||
setLayerOpacity: (index: number, opacity: number) => void;
|
||||
setLayerColor: (index: number, color: string) => void;
|
||||
setLayerHiddenInGame: (index: number, hidden: boolean) => void;
|
||||
renameLayer: (index: number, name: string) => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
entityId: null,
|
||||
pendingFilePath: null,
|
||||
currentFilePath: null,
|
||||
tilesetImageUrl: null,
|
||||
tilesetColumns: 0,
|
||||
tilesetRows: 0,
|
||||
tileWidth: 32,
|
||||
tileHeight: 32,
|
||||
selectedTiles: null,
|
||||
currentTool: 'brush' as TilemapToolType,
|
||||
brushSize: 1,
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
showGrid: true,
|
||||
showCollision: false,
|
||||
currentLayer: 0,
|
||||
layers: [] as LayerState[],
|
||||
editingCollision: false,
|
||||
undoStack: [] as Uint32Array[],
|
||||
redoStack: [] as Uint32Array[],
|
||||
};
|
||||
|
||||
export const useTilemapEditorStore = create<TilemapEditorState>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setEntityId: (id) => set({ entityId: id }),
|
||||
|
||||
setPendingFilePath: (path) => set({ pendingFilePath: path }),
|
||||
|
||||
setCurrentFilePath: (path) => set({ currentFilePath: path }),
|
||||
|
||||
clearPendingFile: () => set({ pendingFilePath: null }),
|
||||
|
||||
setTileset: (url, columns, rows, tileWidth, tileHeight) => set({
|
||||
tilesetImageUrl: url,
|
||||
tilesetColumns: columns,
|
||||
tilesetRows: rows,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
selectedTiles: null,
|
||||
}),
|
||||
|
||||
setSelectedTiles: (selection) => set({ selectedTiles: selection }),
|
||||
|
||||
setCurrentTool: (tool) => set({ currentTool: tool }),
|
||||
|
||||
setBrushSize: (size) => set({ brushSize: Math.max(1, Math.min(10, size)) }),
|
||||
|
||||
setZoom: (zoom) => set({ zoom: Math.max(0.1, Math.min(10, zoom)) }),
|
||||
|
||||
setPan: (x, y) => set({ panX: x, panY: y }),
|
||||
|
||||
setShowGrid: (show) => set({ showGrid: show }),
|
||||
|
||||
setShowCollision: (show) => set({ showCollision: show }),
|
||||
|
||||
setCurrentLayer: (layer) => set({ currentLayer: layer }),
|
||||
|
||||
setEditingCollision: (editing) => set({ editingCollision: editing }),
|
||||
|
||||
pushUndo: (data) => {
|
||||
const { undoStack } = get();
|
||||
set({
|
||||
undoStack: [...undoStack.slice(-49), data],
|
||||
redoStack: [],
|
||||
});
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
const { undoStack, redoStack } = get();
|
||||
if (undoStack.length === 0) return null;
|
||||
|
||||
const data = undoStack[undoStack.length - 1]!;
|
||||
set({
|
||||
undoStack: undoStack.slice(0, -1),
|
||||
redoStack: [...redoStack, data],
|
||||
});
|
||||
return undoStack.length > 1 ? undoStack[undoStack.length - 2]! : null;
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
const { redoStack, undoStack } = get();
|
||||
if (redoStack.length === 0) return null;
|
||||
|
||||
const data = redoStack[redoStack.length - 1]!;
|
||||
set({
|
||||
redoStack: redoStack.slice(0, -1),
|
||||
undoStack: [...undoStack, data],
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
reset: () => set(initialState),
|
||||
|
||||
// Layer management
|
||||
setLayers: (layers) => set({ layers }),
|
||||
|
||||
toggleLayerVisibility: (index) => {
|
||||
const { layers } = get();
|
||||
const layer = layers[index];
|
||||
if (!layer) return;
|
||||
const newLayers = [...layers];
|
||||
newLayers[index] = { ...layer, visible: !layer.visible };
|
||||
set({ layers: newLayers });
|
||||
},
|
||||
|
||||
toggleLayerLocked: (index) => {
|
||||
const { layers } = get();
|
||||
const layer = layers[index];
|
||||
if (!layer) return;
|
||||
const newLayers = [...layers];
|
||||
newLayers[index] = { ...layer, locked: !layer.locked };
|
||||
set({ layers: newLayers });
|
||||
},
|
||||
|
||||
setLayerOpacity: (index, opacity) => {
|
||||
const { layers } = get();
|
||||
const layer = layers[index];
|
||||
if (!layer) return;
|
||||
const newLayers = [...layers];
|
||||
newLayers[index] = { ...layer, opacity: Math.max(0, Math.min(1, opacity)) };
|
||||
set({ layers: newLayers });
|
||||
},
|
||||
|
||||
setLayerColor: (index, color) => {
|
||||
const { layers } = get();
|
||||
const layer = layers[index];
|
||||
if (!layer) return;
|
||||
const newLayers = [...layers];
|
||||
newLayers[index] = { ...layer, color };
|
||||
set({ layers: newLayers });
|
||||
},
|
||||
|
||||
setLayerHiddenInGame: (index, hidden) => {
|
||||
const { layers } = get();
|
||||
const layer = layers[index];
|
||||
if (!layer) return;
|
||||
const newLayers = [...layers];
|
||||
newLayers[index] = { ...layer, hiddenInGame: hidden };
|
||||
set({ layers: newLayers });
|
||||
},
|
||||
|
||||
renameLayer: (index, name) => {
|
||||
const { layers } = get();
|
||||
const layer = layers[index];
|
||||
if (!layer) return;
|
||||
const newLayers = [...layers];
|
||||
newLayers[index] = { ...layer, name };
|
||||
set({ layers: newLayers });
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/* ==================== Tile Set Selector Panel Styles ==================== */
|
||||
|
||||
.tileset-selector-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #252526;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ==================== Mode Toggle ==================== */
|
||||
.tileset-mode-toggle {
|
||||
display: flex;
|
||||
padding: 6px 8px;
|
||||
gap: 2px;
|
||||
background: #1e1e1e;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.mode-toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
background: #2d2d30;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 3px;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.mode-toggle-btn:first-child {
|
||||
border-radius: 3px 0 0 3px;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.mode-toggle-btn:last-child {
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
|
||||
.mode-toggle-btn:hover {
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.mode-toggle-btn.active {
|
||||
background: #094771;
|
||||
border-color: #0078d4;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mode-toggle-btn.active:hover {
|
||||
background: #0a5a8a;
|
||||
}
|
||||
|
||||
.mode-toggle-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* ==================== Tool Buttons Row ==================== */
|
||||
.tileset-tools {
|
||||
display: flex;
|
||||
padding: 8px;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
background: #2d2d30;
|
||||
}
|
||||
|
||||
.tileset-tool-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
background: #252526;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 3px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tileset-tool-btn span {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tileset-tool-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.tileset-tool-btn:hover {
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
border-color: #4c4c4c;
|
||||
}
|
||||
|
||||
.tileset-tool-btn.active {
|
||||
background: linear-gradient(180deg, #ffc107 0%, #e6a800 100%);
|
||||
border-color: #cc9600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.tileset-tool-btn.active:hover {
|
||||
background: linear-gradient(180deg, #ffd54f 0%, #ffca28 100%);
|
||||
}
|
||||
|
||||
.tileset-tool-btn.active svg {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* ==================== Tileset Selector Section ==================== */
|
||||
.tileset-selector {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.tileset-selector-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tileset-selector label {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.tileset-selector-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tileset-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.tileset-action-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.tileset-action-btn:hover {
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* ==================== Tileset Dropdown ==================== */
|
||||
.tileset-dropdown-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tileset-dropdown-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 3px;
|
||||
color: #c0c0c0;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.1s ease;
|
||||
}
|
||||
|
||||
.tileset-dropdown-btn:hover {
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.tileset-dropdown-btn span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tileset-dropdown-btn svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tileset-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 2px;
|
||||
background: #252526;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tileset-dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #c0c0c0;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.tileset-dropdown-item:hover {
|
||||
background: #3c3c3c;
|
||||
}
|
||||
|
||||
.tileset-dropdown-item.selected {
|
||||
background: #094771;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tileset-dropdown-item.add-new {
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
.tileset-dropdown-divider {
|
||||
height: 1px;
|
||||
background: #3c3c3c;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ==================== Header with Zoom and Title ==================== */
|
||||
.tileset-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
background: #2a2a2a;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.tileset-zoom-label {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
margin-right: 8px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.tileset-title {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
/* ==================== Preview Area ==================== */
|
||||
.tileset-preview-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 180px;
|
||||
overflow: auto;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.tileset-empty-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 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 {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: 1px dashed #444;
|
||||
border-radius: 3px;
|
||||
color: #4fc3f7;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tileset-select-btn:hover {
|
||||
background: rgba(79, 195, 247, 0.1);
|
||||
border-color: #4fc3f7;
|
||||
}
|
||||
|
||||
/* ==================== Selection Info ==================== */
|
||||
.tileset-selection-info {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
line-height: 24px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
background: #2d2d30;
|
||||
}
|
||||
|
||||
/* ==================== Scrollbar ==================== */
|
||||
.tileset-preview-area::-webkit-scrollbar,
|
||||
.tileset-dropdown-menu::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.tileset-preview-area::-webkit-scrollbar-track,
|
||||
.tileset-dropdown-menu::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tileset-preview-area::-webkit-scrollbar-thumb,
|
||||
.tileset-dropdown-menu::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.tileset-preview-area::-webkit-scrollbar-thumb:hover,
|
||||
.tileset-dropdown-menu::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
/* ==================== Tilemap Details Panel Styles ==================== */
|
||||
|
||||
.tilemap-details-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #252526;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ==================== Header ==================== */
|
||||
.details-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
background: #2d2d30;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.details-header-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #c0c0c0;
|
||||
}
|
||||
|
||||
.details-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.details-search-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.details-search-inline svg {
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.details-search-inline input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #c0c0c0;
|
||||
font-size: 11px;
|
||||
width: 60px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.details-search-inline input::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.details-settings-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.details-settings-btn:hover {
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* ==================== Content ==================== */
|
||||
.details-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.details-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #555;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ==================== Section Styles ==================== */
|
||||
.details-section {
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.details-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #c0c0c0;
|
||||
background: #2a2a2a;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.details-section-header:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.details-section-header svg {
|
||||
color: #888;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.details-section-content {
|
||||
padding: 6px 0;
|
||||
background: #252526;
|
||||
}
|
||||
|
||||
/* ==================== Property Row ==================== */
|
||||
.property-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 26px;
|
||||
padding: 0 10px 0 20px;
|
||||
}
|
||||
|
||||
.property-row:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.property-row.indented {
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.property-row label {
|
||||
flex: 0 0 100px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.property-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ==================== Layer Count Badge ==================== */
|
||||
.layer-count-badge {
|
||||
font-size: 11px;
|
||||
color: #4fc3f7;
|
||||
background: rgba(79, 195, 247, 0.15);
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ==================== Input Styles ==================== */
|
||||
.property-row input[type="number"],
|
||||
.property-row input[type="text"],
|
||||
.property-row select {
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 2px;
|
||||
color: #c0c0c0;
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.property-row input[type="number"]:focus,
|
||||
.property-row input[type="text"]:focus,
|
||||
.property-row select:focus {
|
||||
border-color: #0078d4;
|
||||
}
|
||||
|
||||
/* ==================== Toggle Row ==================== */
|
||||
.toggle-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* ==================== Property Toggle - Unified Style ==================== */
|
||||
.property-toggle {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.property-toggle-off {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.property-toggle-off:hover:not(:disabled) {
|
||||
background: #333;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.property-toggle-on {
|
||||
background: #3b82f6;
|
||||
border: 1px solid #3b82f6;
|
||||
}
|
||||
|
||||
.property-toggle-on:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.property-toggle:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.property-toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
transition: transform 0.15s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.property-toggle-off .property-toggle-thumb {
|
||||
left: 2px;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.property-toggle-on .property-toggle-thumb {
|
||||
left: 2px;
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
/* Color Field */
|
||||
.property-color-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.property-color-preview {
|
||||
width: 32px;
|
||||
height: 22px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid #3a3a3a;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.property-color-preview:hover {
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.property-input-color {
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.property-input-color-text {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.property-input {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
padding: 0 6px;
|
||||
color: #ddd;
|
||||
font-size: 11px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.property-input:hover {
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.property-input:focus {
|
||||
outline: none;
|
||||
border-color: #d4a029;
|
||||
}
|
||||
|
||||
/* ==================== Asset Dropdown Button ==================== */
|
||||
.asset-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 2px;
|
||||
color: #c0c0c0;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.asset-dropdown:hover {
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.asset-dropdown span {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-dropdown svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ==================== Material Field - AssetField Style ==================== */
|
||||
.material-section-content {
|
||||
padding: 8px 10px 8px 20px;
|
||||
}
|
||||
|
||||
.material-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.material-field__label {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.material-field__content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Thumbnail Preview */
|
||||
.material-field__thumbnail {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.material-field__thumbnail:hover {
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.material-field__thumbnail-icon {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Right side container */
|
||||
.material-field__right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Dropdown selector */
|
||||
.material-field__dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.material-field__dropdown:hover {
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.material-field__value {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.material-field__dropdown.has-value .material-field__value {
|
||||
color: #ddd;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.material-field__dropdown-arrow {
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Action buttons row */
|
||||
.material-field__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.material-field__btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.material-field__btn:hover {
|
||||
background: #3a3a3a;
|
||||
border-color: #4a4a4a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.material-field__btn--clear:hover {
|
||||
background: #4a2020;
|
||||
border-color: #5a3030;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* ==================== Layer List ==================== */
|
||||
.layer-list-container {
|
||||
margin: 4px 10px 4px 20px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 2px;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.layer-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
color: #c0c0c0;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.layer-list-item:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.layer-list-item.selected {
|
||||
background: #094771;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.layer-list-item .layer-icon {
|
||||
font-size: 9px;
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
/* ==================== Layer Visibility Button ==================== */
|
||||
.layer-visibility-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: #4fc3f7;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layer-visibility-btn:hover {
|
||||
background: rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
.layer-visibility-btn.hidden {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.layer-visibility-btn.hidden:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.layer-list-item.selected .layer-visibility-btn {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.layer-list-item.selected .layer-visibility-btn.hidden {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* ==================== Layer Action Buttons ==================== */
|
||||
.layer-actions-row {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 4px 10px 6px 20px;
|
||||
}
|
||||
|
||||
.layer-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: #2d2d30;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 2px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.layer-action-btn svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.layer-action-btn:hover:not(:disabled) {
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.layer-action-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.layer-action-btn.add {
|
||||
background: #1a3a1a;
|
||||
border-color: #2a5a2a;
|
||||
color: #5cb85c;
|
||||
}
|
||||
|
||||
.layer-action-btn.add:hover {
|
||||
background: #2a5a2a;
|
||||
color: #7cd87c;
|
||||
}
|
||||
|
||||
.layer-action-btn.danger:hover:not(:disabled) {
|
||||
background: #5a2020;
|
||||
border-color: #7a3030;
|
||||
color: #f06060;
|
||||
}
|
||||
|
||||
/* ==================== Selected Layer Name ==================== */
|
||||
.selected-layer-name {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #c0c0c0;
|
||||
}
|
||||
|
||||
.selected-layer-name.editable {
|
||||
cursor: text;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.selected-layer-name.editable:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.layer-name-input {
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #0078d4;
|
||||
border-radius: 2px;
|
||||
color: #c0c0c0;
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ==================== Select Dropdown ==================== */
|
||||
.property-row select {
|
||||
appearance: none;
|
||||
padding-right: 20px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 6px center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ==================== Slider Property ==================== */
|
||||
.slider-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.property-slider {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: #3c3c3c;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.property-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #0078d4;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.property-slider::-webkit-slider-thumb:hover {
|
||||
background: #1a8cff;
|
||||
}
|
||||
|
||||
.property-slider::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #0078d4;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
/* ==================== Scrollbar ==================== */
|
||||
.details-content::-webkit-scrollbar,
|
||||
.layer-list-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.details-content::-webkit-scrollbar-track,
|
||||
.layer-list-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.details-content::-webkit-scrollbar-thumb,
|
||||
.layer-list-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.details-content::-webkit-scrollbar-thumb:hover,
|
||||
.layer-list-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
/* ==================== Tilemap Editor Styles ==================== */
|
||||
|
||||
/* Main Panel - 3-Column Layout */
|
||||
.tilemap-editor-panel {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: #1e1e1e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* ==================== Panel Divider ==================== */
|
||||
.panel-divider {
|
||||
flex-shrink: 0;
|
||||
background: #1a1a1a;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.panel-divider.horizontal {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.panel-divider.vertical {
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.panel-divider:hover {
|
||||
background: #0078d4;
|
||||
}
|
||||
|
||||
.panel-divider:active {
|
||||
background: #005a9e;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.tilemap-editor-panel .tilemap-editor-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tilemap-editor-panel .tilemap-editor-empty svg {
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.tilemap-editor-panel .tilemap-editor-empty h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.tilemap-editor-panel .tilemap-editor-empty p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ==================== Center Viewport ==================== */
|
||||
.tilemap-viewport {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: #1a1a1a;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ==================== Viewport Toolbar - 优化样式 ==================== */
|
||||
.viewport-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 6px;
|
||||
background: #2d2d30;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.viewport-toolbar-left,
|
||||
.viewport-toolbar-center,
|
||||
.viewport-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.viewport-toolbar-center {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.viewport-btn-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
background: #252526;
|
||||
border-radius: 3px;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.viewport-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: #a0a0a0;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewport-btn:hover {
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.viewport-btn.active {
|
||||
background: #094771;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.viewport-btn.icon {
|
||||
width: 24px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.viewport-btn.snap-btn {
|
||||
padding: 0 6px;
|
||||
gap: 2px;
|
||||
font-size: 10px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.viewport-btn.snap-btn svg {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.viewport-separator {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: #3c3c3c;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.zoom-display {
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #a0a0a0;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
/* Exit fullscreen button */
|
||||
.viewport-btn.exit-fullscreen {
|
||||
background: linear-gradient(180deg, #e05a50 0%, #c0443a 100%);
|
||||
color: #fff;
|
||||
padding: 0 10px;
|
||||
gap: 4px;
|
||||
font-weight: 500;
|
||||
border: 1px solid #a03028;
|
||||
}
|
||||
|
||||
.viewport-btn.exit-fullscreen:hover {
|
||||
background: linear-gradient(180deg, #f06a60 0%, #d0544a 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ==================== Info Overlay ==================== */
|
||||
.viewport-info-overlay {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 8px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 6px 8px;
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewport-info-overlay .info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #888;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.viewport-info-overlay .info-item strong {
|
||||
color: #c0c0c0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.viewport-info-overlay .info-item.warning {
|
||||
color: #e6a700;
|
||||
}
|
||||
|
||||
.viewport-info-overlay .info-item.warning svg {
|
||||
flex-shrink: 0;
|
||||
color: #e6a700;
|
||||
}
|
||||
|
||||
/* ==================== Canvas Container ==================== */
|
||||
.viewport-canvas-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
background:
|
||||
linear-gradient(45deg, #1a1a1a 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #1a1a1a 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #1a1a1a 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #1a1a1a 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
.tilemap-canvas-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tilemap-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ==================== Ruler ==================== */
|
||||
.viewport-ruler {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ruler-marker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ruler-line {
|
||||
width: 100px;
|
||||
height: 2px;
|
||||
background: #555;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ruler-line::before,
|
||||
.ruler-line::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
width: 2px;
|
||||
height: 10px;
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.ruler-line::before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.ruler-line::after {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.ruler-marker span {
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* ==================== Watermark ==================== */
|
||||
.viewport-watermark {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.08);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* ==================== Icon Buttons ==================== */
|
||||
.icon-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.icon-button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon-button.small {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.icon-button.danger:hover {
|
||||
background: #5a1d1d;
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
/* ==================== Asset Picker Dialog ==================== */
|
||||
.asset-picker-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.asset-picker-dialog {
|
||||
width: 500px;
|
||||
max-height: 600px;
|
||||
background: #252526;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.asset-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #3c3c3c;
|
||||
}
|
||||
|
||||
.asset-picker-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.asset-picker-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.asset-picker-close:hover {
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.asset-picker-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #3c3c3c;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.asset-picker-search input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #e0e0e0;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.asset-picker-search input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.asset-picker-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 300px;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.asset-picker-tree {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.asset-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.asset-picker-item:hover {
|
||||
background: #2d2d30;
|
||||
}
|
||||
|
||||
.asset-picker-item.selected {
|
||||
background: #094771;
|
||||
}
|
||||
|
||||
.asset-picker-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.asset-picker-item-name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.asset-picker-loading,
|
||||
.asset-picker-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.asset-picker-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #3c3c3c;
|
||||
}
|
||||
|
||||
.asset-picker-selected {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.asset-picker-selected .placeholder {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.asset-picker-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.asset-picker-actions button {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-cancel {
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-cancel:hover {
|
||||
background: #4c4c4c;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-confirm {
|
||||
background: #0078d4;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-confirm:hover:not(:disabled) {
|
||||
background: #106ebe;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-confirm:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.asset-picker-preview {
|
||||
position: fixed;
|
||||
z-index: 1001;
|
||||
background: #252526;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.asset-picker-preview img {
|
||||
display: block;
|
||||
max-width: 150px;
|
||||
max-height: 150px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* ==================== Tileset Canvas ==================== */
|
||||
.tileset-canvas {
|
||||
cursor: crosshair;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
134
packages/editor/plugins/tilemap-editor/src/tools/BrushTool.ts
Normal file
134
packages/editor/plugins/tilemap-editor/src/tools/BrushTool.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Brush Tool - Paint tiles on the tilemap
|
||||
*/
|
||||
|
||||
import type { ITilemapTool, ToolContext } from './ITilemapTool';
|
||||
|
||||
export class BrushTool implements ITilemapTool {
|
||||
readonly id = 'brush';
|
||||
readonly name = 'Brush';
|
||||
readonly icon = 'Paintbrush';
|
||||
readonly cursor = 'crosshair';
|
||||
|
||||
private _isDrawing = false;
|
||||
private _lastTileX = -1;
|
||||
private _lastTileY = -1;
|
||||
|
||||
onMouseDown(tileX: number, tileY: number, ctx: ToolContext): void {
|
||||
if (ctx.layerLocked && !ctx.editingCollision) return;
|
||||
this._isDrawing = true;
|
||||
this._lastTileX = tileX;
|
||||
this._lastTileY = tileY;
|
||||
this.paint(tileX, tileY, ctx);
|
||||
}
|
||||
|
||||
onMouseMove(tileX: number, tileY: number, ctx: ToolContext): void {
|
||||
if (!this._isDrawing || (ctx.layerLocked && !ctx.editingCollision)) return;
|
||||
if (tileX === this._lastTileX && tileY === this._lastTileY) return;
|
||||
|
||||
// Line drawing between last and current position
|
||||
this.drawLine(this._lastTileX, this._lastTileY, tileX, tileY, ctx);
|
||||
this._lastTileX = tileX;
|
||||
this._lastTileY = tileY;
|
||||
}
|
||||
|
||||
onMouseUp(_tileX: number, _tileY: number, _ctx: ToolContext): void {
|
||||
this._isDrawing = false;
|
||||
this._lastTileX = -1;
|
||||
this._lastTileY = -1;
|
||||
}
|
||||
|
||||
getPreviewTiles(tileX: number, tileY: number, ctx: ToolContext): { x: number; y: number }[] {
|
||||
const tiles: { x: number; y: number }[] = [];
|
||||
const selection = ctx.selectedTiles;
|
||||
|
||||
if (!selection) {
|
||||
// Single tile brush
|
||||
const halfSize = Math.floor(ctx.brushSize / 2);
|
||||
for (let dy = -halfSize; dy <= halfSize; dy++) {
|
||||
for (let dx = -halfSize; dx <= halfSize; dx++) {
|
||||
tiles.push({ x: tileX + dx, y: tileY + dy });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Multi-tile brush
|
||||
for (let dy = 0; dy < selection.height; dy++) {
|
||||
for (let dx = 0; dx < selection.width; dx++) {
|
||||
tiles.push({ x: tileX + dx, y: tileY + dy });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private paint(tileX: number, tileY: number, ctx: ToolContext): void {
|
||||
const { tilemap, selectedTiles, brushSize, editingCollision, currentLayer } = ctx;
|
||||
|
||||
if (editingCollision) {
|
||||
// Paint collision
|
||||
const halfSize = Math.floor(brushSize / 2);
|
||||
for (let dy = -halfSize; dy <= halfSize; dy++) {
|
||||
for (let dx = -halfSize; dx <= halfSize; dx++) {
|
||||
const x = tileX + dx;
|
||||
const y = tileY + dy;
|
||||
if (x >= 0 && x < tilemap.width && y >= 0 && y < tilemap.height) {
|
||||
tilemap.setCollision(x, y, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (selectedTiles) {
|
||||
// Paint selected tiles
|
||||
for (let dy = 0; dy < selectedTiles.height; dy++) {
|
||||
for (let dx = 0; dx < selectedTiles.width; dx++) {
|
||||
const x = tileX + dx;
|
||||
const y = tileY + dy;
|
||||
if (x >= 0 && x < tilemap.width && y >= 0 && y < tilemap.height) {
|
||||
const tileIndex = selectedTiles.tiles[dy * selectedTiles.width + dx] ?? 0;
|
||||
tilemap.setTile(currentLayer, x, y, tileIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No selection, paint tile 0 with brush size
|
||||
const halfSize = Math.floor(brushSize / 2);
|
||||
for (let dy = -halfSize; dy <= halfSize; dy++) {
|
||||
for (let dx = -halfSize; dx <= halfSize; dx++) {
|
||||
const x = tileX + dx;
|
||||
const y = tileY + dy;
|
||||
if (x >= 0 && x < tilemap.width && y >= 0 && y < tilemap.height) {
|
||||
tilemap.setTile(currentLayer, x, y, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawLine(x0: number, y0: number, x1: number, y1: number, ctx: ToolContext): void {
|
||||
// Bresenham's line algorithm
|
||||
const dx = Math.abs(x1 - x0);
|
||||
const dy = Math.abs(y1 - y0);
|
||||
const sx = x0 < x1 ? 1 : -1;
|
||||
const sy = y0 < y1 ? 1 : -1;
|
||||
let err = dx - dy;
|
||||
|
||||
let x = x0;
|
||||
let y = y0;
|
||||
|
||||
while (true) {
|
||||
this.paint(x, y, ctx);
|
||||
|
||||
if (x === x1 && y === y1) break;
|
||||
|
||||
const e2 = 2 * err;
|
||||
if (e2 > -dy) {
|
||||
err -= dy;
|
||||
x += sx;
|
||||
}
|
||||
if (e2 < dx) {
|
||||
err += dx;
|
||||
y += sy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Eraser Tool - Remove tiles from the tilemap
|
||||
*/
|
||||
|
||||
import type { ITilemapTool, ToolContext } from './ITilemapTool';
|
||||
|
||||
export class EraserTool implements ITilemapTool {
|
||||
readonly id = 'eraser';
|
||||
readonly name = 'Eraser';
|
||||
readonly icon = 'Eraser';
|
||||
readonly cursor = 'crosshair';
|
||||
|
||||
private _isErasing = false;
|
||||
private _lastTileX = -1;
|
||||
private _lastTileY = -1;
|
||||
|
||||
onMouseDown(tileX: number, tileY: number, ctx: ToolContext): void {
|
||||
if (ctx.layerLocked && !ctx.editingCollision) return;
|
||||
this._isErasing = true;
|
||||
this._lastTileX = tileX;
|
||||
this._lastTileY = tileY;
|
||||
this.erase(tileX, tileY, ctx);
|
||||
}
|
||||
|
||||
onMouseMove(tileX: number, tileY: number, ctx: ToolContext): void {
|
||||
if (!this._isErasing || (ctx.layerLocked && !ctx.editingCollision)) return;
|
||||
if (tileX === this._lastTileX && tileY === this._lastTileY) return;
|
||||
|
||||
this.drawLine(this._lastTileX, this._lastTileY, tileX, tileY, ctx);
|
||||
this._lastTileX = tileX;
|
||||
this._lastTileY = tileY;
|
||||
}
|
||||
|
||||
onMouseUp(_tileX: number, _tileY: number, _ctx: ToolContext): void {
|
||||
this._isErasing = false;
|
||||
this._lastTileX = -1;
|
||||
this._lastTileY = -1;
|
||||
}
|
||||
|
||||
getPreviewTiles(tileX: number, tileY: number, ctx: ToolContext): { x: number; y: number }[] {
|
||||
const tiles: { x: number; y: number }[] = [];
|
||||
const halfSize = Math.floor(ctx.brushSize / 2);
|
||||
|
||||
for (let dy = -halfSize; dy <= halfSize; dy++) {
|
||||
for (let dx = -halfSize; dx <= halfSize; dx++) {
|
||||
tiles.push({ x: tileX + dx, y: tileY + dy });
|
||||
}
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private erase(tileX: number, tileY: number, ctx: ToolContext): void {
|
||||
const { tilemap, brushSize, editingCollision, currentLayer } = ctx;
|
||||
const halfSize = Math.floor(brushSize / 2);
|
||||
|
||||
for (let dy = -halfSize; dy <= halfSize; dy++) {
|
||||
for (let dx = -halfSize; dx <= halfSize; dx++) {
|
||||
const x = tileX + dx;
|
||||
const y = tileY + dy;
|
||||
if (x >= 0 && x < tilemap.width && y >= 0 && y < tilemap.height) {
|
||||
if (editingCollision) {
|
||||
tilemap.setCollision(x, y, 0);
|
||||
} else {
|
||||
tilemap.setTile(currentLayer, x, y, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawLine(x0: number, y0: number, x1: number, y1: number, ctx: ToolContext): void {
|
||||
const dx = Math.abs(x1 - x0);
|
||||
const dy = Math.abs(y1 - y0);
|
||||
const sx = x0 < x1 ? 1 : -1;
|
||||
const sy = y0 < y1 ? 1 : -1;
|
||||
let err = dx - dy;
|
||||
|
||||
let x = x0;
|
||||
let y = y0;
|
||||
|
||||
while (true) {
|
||||
this.erase(x, y, ctx);
|
||||
|
||||
if (x === x1 && y === y1) break;
|
||||
|
||||
const e2 = 2 * err;
|
||||
if (e2 > -dy) {
|
||||
err -= dy;
|
||||
x += sx;
|
||||
}
|
||||
if (e2 < dx) {
|
||||
err += dx;
|
||||
y += sy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
130
packages/editor/plugins/tilemap-editor/src/tools/FillTool.ts
Normal file
130
packages/editor/plugins/tilemap-editor/src/tools/FillTool.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Fill Tool - Flood fill tiles on the tilemap
|
||||
*/
|
||||
|
||||
import type { ITilemapTool, ToolContext } from './ITilemapTool';
|
||||
|
||||
export class FillTool implements ITilemapTool {
|
||||
readonly id = 'fill';
|
||||
readonly name = 'Fill';
|
||||
readonly icon = 'PaintBucket';
|
||||
readonly cursor = 'crosshair';
|
||||
|
||||
onMouseDown(tileX: number, tileY: number, ctx: ToolContext): void {
|
||||
if (ctx.layerLocked && !ctx.editingCollision) return;
|
||||
this.floodFill(tileX, tileY, ctx);
|
||||
}
|
||||
|
||||
onMouseMove(_tileX: number, _tileY: number, _ctx: ToolContext): void {
|
||||
// No action on move
|
||||
}
|
||||
|
||||
onMouseUp(_tileX: number, _tileY: number, _ctx: ToolContext): void {
|
||||
// No action on up
|
||||
}
|
||||
|
||||
getPreviewTiles(tileX: number, tileY: number, ctx: ToolContext): { x: number; y: number }[] {
|
||||
const { tilemap, editingCollision, currentLayer } = ctx;
|
||||
|
||||
if (tileX < 0 || tileX >= tilemap.width || tileY < 0 || tileY >= tilemap.height) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tiles: { x: number; y: number }[] = [];
|
||||
const maxPreviewTiles = 500;
|
||||
|
||||
if (editingCollision) {
|
||||
const targetCollision = tilemap.hasCollision(tileX, tileY);
|
||||
const stack: [number, number][] = [[tileX, tileY]];
|
||||
const visited = new Set<string>();
|
||||
|
||||
while (stack.length > 0 && tiles.length < maxPreviewTiles) {
|
||||
const [x, y] = stack.pop()!;
|
||||
const key = `${x},${y}`;
|
||||
|
||||
if (visited.has(key)) continue;
|
||||
if (x < 0 || x >= tilemap.width || y < 0 || y >= tilemap.height) continue;
|
||||
if (tilemap.hasCollision(x, y) !== targetCollision) continue;
|
||||
|
||||
visited.add(key);
|
||||
tiles.push({ x, y });
|
||||
|
||||
stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
|
||||
}
|
||||
} else {
|
||||
const targetTile = tilemap.getTile(currentLayer, tileX, tileY);
|
||||
const stack: [number, number][] = [[tileX, tileY]];
|
||||
const visited = new Set<string>();
|
||||
|
||||
while (stack.length > 0 && tiles.length < maxPreviewTiles) {
|
||||
const [x, y] = stack.pop()!;
|
||||
const key = `${x},${y}`;
|
||||
|
||||
if (visited.has(key)) continue;
|
||||
if (x < 0 || x >= tilemap.width || y < 0 || y >= tilemap.height) continue;
|
||||
if (tilemap.getTile(currentLayer, x, y) !== targetTile) continue;
|
||||
|
||||
visited.add(key);
|
||||
tiles.push({ x, y });
|
||||
|
||||
stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private floodFill(startX: number, startY: number, ctx: ToolContext): void {
|
||||
const { tilemap, selectedTiles, editingCollision, currentLayer } = ctx;
|
||||
|
||||
if (startX < 0 || startX >= tilemap.width || startY < 0 || startY >= tilemap.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingCollision) {
|
||||
// Flood fill collision
|
||||
const targetCollision = tilemap.hasCollision(startX, startY);
|
||||
const newCollision = targetCollision ? 0 : 1;
|
||||
|
||||
const stack: [number, number][] = [[startX, startY]];
|
||||
const visited = new Set<string>();
|
||||
|
||||
while (stack.length > 0) {
|
||||
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);
|
||||
tilemap.setCollision(x, y, newCollision);
|
||||
|
||||
stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
|
||||
}
|
||||
} else {
|
||||
// Flood fill tiles
|
||||
const targetTile = tilemap.getTile(currentLayer, startX, startY);
|
||||
const newTile = selectedTiles ? (selectedTiles.tiles[0] ?? 1) : 1;
|
||||
|
||||
if (targetTile === newTile) return;
|
||||
|
||||
const stack: [number, number][] = [[startX, startY]];
|
||||
const visited = new Set<string>();
|
||||
|
||||
while (stack.length > 0) {
|
||||
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);
|
||||
tilemap.setTile(currentLayer, x, y, newTile);
|
||||
|
||||
stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Tilemap Tool Interface
|
||||
*/
|
||||
|
||||
import type { TilemapComponent } from '@esengine/tilemap';
|
||||
import type { TileSelection } from '../stores/TilemapEditorStore';
|
||||
|
||||
export interface ToolContext {
|
||||
tilemap: TilemapComponent;
|
||||
selectedTiles: TileSelection | null;
|
||||
currentLayer: number;
|
||||
layerLocked: boolean;
|
||||
brushSize: number;
|
||||
editingCollision: boolean;
|
||||
tileWidth: number;
|
||||
tileHeight: number;
|
||||
}
|
||||
|
||||
export interface ITilemapTool {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly icon: string;
|
||||
readonly cursor: string;
|
||||
|
||||
onMouseDown(tileX: number, tileY: number, ctx: ToolContext): void;
|
||||
onMouseMove(tileX: number, tileY: number, ctx: ToolContext): void;
|
||||
onMouseUp(tileX: number, tileY: number, ctx: ToolContext): void;
|
||||
|
||||
// Preview tiles to highlight during drag
|
||||
getPreviewTiles?(tileX: number, tileY: number, ctx: ToolContext): { x: number; y: number }[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
23
packages/editor/plugins/tilemap-editor/tsconfig.build.json
Normal file
23
packages/editor/plugins/tilemap-editor/tsconfig.build.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"jsx": "react-jsx",
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
28
packages/editor/plugins/tilemap-editor/tsconfig.json
Normal file
28
packages/editor/plugins/tilemap-editor/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../framework/core"
|
||||
},
|
||||
{
|
||||
"path": "../../../editor/editor-core"
|
||||
},
|
||||
{
|
||||
"path": "../../../rendering/tilemap"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
packages/editor/plugins/tilemap-editor/tsup.config.ts
Normal file
7
packages/editor/plugins/tilemap-editor/tsup.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
import { editorOnlyPreset } from '../../../tools/build-config/src/presets/plugin-tsup';
|
||||
|
||||
export default defineConfig({
|
||||
...editorOnlyPreset(),
|
||||
tsconfig: 'tsconfig.build.json'
|
||||
});
|
||||
Reference in New Issue
Block a user