Feature/runtime cdn and plugin loader (#240)

* feat(ui): 完善 UI 布局系统和编辑器可视化工具

* refactor: 移除 ModuleRegistry,统一使用 PluginManager 插件系统

* fix: 修复 CodeQL 警告并提升测试覆盖率

* refactor: 分离运行时入口点,解决 runtime bundle 包含 React 的问题

* fix(ci): 添加 editor-core 和 editor-runtime 到 CI 依赖构建步骤

* docs: 完善 ServiceContainer 文档,新增 Symbol.for 模式和 @InjectProperty 说明

* fix(ci): 修复 type-check 失败问题

* fix(ci): 修复类型检查失败问题

* fix(ci): 修复类型检查失败问题

* fix(ci): behavior-tree 构建添加 @tauri-apps 外部依赖

* fix(ci): behavior-tree 添加 @tauri-apps/plugin-fs 类型依赖

* fix(ci): platform-web 添加缺失的 behavior-tree 依赖

* fix(lint): 移除正则表达式中不必要的转义字符
This commit is contained in:
YHH
2025-11-27 20:42:46 +08:00
committed by GitHub
parent 71869b1a58
commit 107439d70c
367 changed files with 10661 additions and 12473 deletions

View File

@@ -2,20 +2,28 @@
"name": "@esengine/tilemap",
"version": "1.0.0",
"description": "Tilemap system for ECS Framework - supports Tiled editor import",
"main": "bin/index.js",
"types": "bin/index.d.ts",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./bin/index.d.ts",
"import": "./bin/index.js",
"development": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
}
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./runtime": {
"types": "./dist/runtime.d.ts",
"import": "./dist/runtime.js"
},
"./editor": {
"types": "./dist/editor/index.d.ts",
"import": "./dist/editor/index.js"
},
"./plugin.json": "./plugin.json"
},
"files": [
"bin/**/*"
"dist",
"plugin.json"
],
"keywords": [
"ecs",
@@ -26,24 +34,45 @@
"typescript"
],
"scripts": {
"clean": "rimraf bin dist tsconfig.tsbuildinfo",
"build:ts": "tsc",
"prebuild": "npm run clean",
"build": "npm run build:ts",
"build:watch": "tsc --watch",
"clean": "rimraf dist tsconfig.tsbuildinfo",
"build": "vite build",
"build:watch": "vite build --watch",
"type-check": "tsc --noEmit",
"rebuild": "npm run clean && npm run build"
},
"author": "yhh",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.3.12",
"@vitejs/plugin-react": "^4.7.0",
"rimraf": "^5.0.0",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"vite": "^6.0.7",
"vite-plugin-dts": "^3.7.0"
},
"peerDependencies": {
"@esengine/ecs-framework": "^2.2.8",
"@esengine/asset-system": "workspace:*",
"@esengine/ecs-components": "workspace:*",
"@esengine/ecs-engine-bindgen": "workspace:*"
"@esengine/ecs-engine-bindgen": "workspace:*",
"@esengine/editor-core": "workspace:*",
"react": "^18.3.1",
"zustand": "^5.0.8",
"lucide-react": "^0.545.0"
},
"peerDependenciesMeta": {
"@esengine/editor-core": {
"optional": true
},
"react": {
"optional": true
},
"zustand": {
"optional": true
},
"lucide-react": {
"optional": true
}
},
"dependencies": {
"tslib": "^2.8.1"

View File

@@ -0,0 +1,41 @@
{
"id": "@esengine/tilemap",
"name": "Tilemap System",
"version": "1.0.0",
"description": "瓦片地图系统,支持 Tiled 格式导入和高效渲染 | Tilemap system with Tiled format import and efficient rendering",
"author": "ESEngine Team",
"license": "MIT",
"category": "rendering",
"tags": ["tilemap", "tiled", "2d", "rendering"],
"icon": "Grid3X3",
"enabledByDefault": true,
"canContainContent": true,
"isEnginePlugin": true,
"isCore": false,
"modules": [
{
"name": "TilemapRuntime",
"type": "runtime",
"loadingPhase": "default",
"entry": "./src/index.ts",
"components": ["TilemapComponent"],
"systems": ["TilemapRenderingSystem"]
},
{
"name": "TilemapEditor",
"type": "editor",
"loadingPhase": "default",
"entry": "./src/editor/index.ts",
"panels": ["tilemap-editor"],
"inspectors": ["TilemapInspectorProvider"],
"gizmoProviders": ["TilemapGizmoProvider"]
}
],
"dependencies": [
{
"id": "@esengine/core",
"version": "^1.0.0"
}
],
"platforms": ["web", "desktop"]
}

View File

@@ -0,0 +1,32 @@
/**
* Tilemap Runtime Module (Pure runtime, no editor dependencies)
* Tilemap 运行时模块(纯运行时,无编辑器依赖)
*/
import type { IScene } from '@esengine/ecs-framework';
import { ComponentRegistry } from '@esengine/ecs-framework';
import type { IRuntimeModuleLoader, SystemContext } from '@esengine/ecs-components';
import { TilemapComponent } from './TilemapComponent';
import { TilemapRenderingSystem } from './systems/TilemapRenderingSystem';
/**
* Tilemap Runtime Module
* Tilemap 运行时模块
*/
export class TilemapRuntimeModule implements IRuntimeModuleLoader {
registerComponents(registry: typeof ComponentRegistry): void {
registry.register(TilemapComponent);
}
createSystems(scene: IScene, context: SystemContext): void {
const tilemapSystem = new TilemapRenderingSystem();
scene.addSystem(tilemapSystem);
if (context.renderSystem) {
context.renderSystem.addRenderDataProvider(tilemapSystem);
}
context.tilemapSystem = tilemapSystem;
}
}

View File

@@ -0,0 +1,89 @@
/**
* Tilemap 统一插件
* Tilemap Unified Plugin
*
* 整合运行时模块和编辑器模块
* Integrates runtime and editor modules
*/
import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
import { ComponentRegistry } from '@esengine/ecs-framework';
import type {
IPluginLoader,
IRuntimeModuleLoader,
PluginDescriptor,
SystemContext
} from '@esengine/editor-core';
// Runtime imports
import { TilemapComponent } from '../TilemapComponent';
import { TilemapRenderingSystem } from '../systems/TilemapRenderingSystem';
// Editor imports
import { TilemapEditorModule } from './index';
/**
* 插件描述符
*/
const descriptor: PluginDescriptor = {
id: '@esengine/tilemap',
name: 'Tilemap System',
version: '1.0.0',
description: '瓦片地图系统,支持 Tiled 格式导入和高效渲染',
category: 'rendering',
enabledByDefault: true,
canContainContent: true,
isEnginePlugin: true,
modules: [
{
name: 'TilemapRuntime',
type: 'runtime',
loadingPhase: 'default',
entry: './src/index.ts'
},
{
name: 'TilemapEditor',
type: 'editor',
loadingPhase: 'default',
entry: './src/editor/index.ts'
}
],
dependencies: [
{ id: '@esengine/core', version: '^1.0.0' }
],
icon: 'Grid3X3'
};
/**
* Tilemap 运行时模块
* Tilemap runtime module
*/
export class TilemapRuntimeModule implements IRuntimeModuleLoader {
registerComponents(registry: typeof ComponentRegistry): void {
registry.register(TilemapComponent);
}
createSystems(scene: IScene, context: SystemContext): void {
const tilemapSystem = new TilemapRenderingSystem();
scene.addSystem(tilemapSystem);
if (context.renderSystem) {
context.renderSystem.addRenderDataProvider(tilemapSystem);
}
// 保存引用供其他系统使用 | Save reference for other systems
context.tilemapSystem = tilemapSystem;
}
}
/**
* Tilemap 插件加载器
* Tilemap plugin loader
*/
export const TilemapPlugin: IPluginLoader = {
descriptor,
runtimeModule: new TilemapRuntimeModule(),
editorModule: new TilemapEditorModule(),
};
export default TilemapPlugin;

View File

@@ -0,0 +1,364 @@
/**
* Tilemap Canvas - Main editing canvas
*/
import React, { useRef, useEffect, useState, useCallback } from 'react';
import type { TilemapComponent } from '../../TilemapComponent';
import { useTilemapEditorStore } from '../stores/TilemapEditorStore';
import type { ITilemapTool, ToolContext } from '../tools/ITilemapTool';
import { BrushTool } from '../tools/BrushTool';
import { EraserTool } from '../tools/EraserTool';
import { FillTool } from '../tools/FillTool';
interface TilemapCanvasProps {
tilemap: TilemapComponent;
tilesetImage: HTMLImageElement | null;
onTilemapChange?: () => void;
}
const tools: Record<string, ITilemapTool> = {
brush: new BrushTool(),
eraser: new EraserTool(),
fill: new FillTool(),
};
export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
tilemap,
tilesetImage,
onTilemapChange,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const {
currentTool,
zoom,
panX,
panY,
showGrid,
showCollision,
selectedTiles,
brushSize,
currentLayer,
editingCollision,
tileWidth,
tileHeight,
tilesetColumns,
layers,
setPan,
setZoom,
pushUndo,
} = useTilemapEditorStore();
// Get layer locked state
const layerLocked = layers[currentLayer]?.locked ?? false;
// Create a dependency key from layers state to trigger redraw when visibility/opacity changes
const layersKey = layers.map(l => `${l.visible}-${l.opacity}`).join(',');
const [isPanning, setIsPanning] = useState(false);
const [lastPanPos, setLastPanPos] = useState({ x: 0, y: 0 });
const [mousePos, setMousePos] = useState<{ tileX: number; tileY: number } | null>(null);
// Get canvas size
const canvasWidth = tilemap.width * tileWidth;
const canvasHeight = tilemap.height * tileHeight;
// Draw the tilemap
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Clear
ctx.fillStyle = '#2d2d2d';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(panX, panY);
ctx.scale(zoom, zoom);
// Draw tilemap background
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
// Draw tiles from all visible layers (from bottom to top)
if (tilesetImage) {
ctx.imageSmoothingEnabled = false;
// Draw all layers from tilemap component, respecting visibility and opacity
const tilemapLayers = tilemap.layers;
for (let layerIndex = tilemapLayers.length - 1; layerIndex >= 0; layerIndex--) {
const tilemapLayer = tilemapLayers[layerIndex];
if (!tilemapLayer || !tilemapLayer.visible) continue; // Skip undefined or invisible layers
// Apply layer opacity
const savedAlpha = ctx.globalAlpha;
ctx.globalAlpha = tilemapLayer.opacity ?? 1;
for (let y = 0; y < tilemap.height; y++) {
for (let x = 0; x < tilemap.width; x++) {
const tileIndex = tilemap.getTile(layerIndex, x, y);
if (tileIndex > 0) {
// Calculate source position in tileset
const srcX = ((tileIndex - 1) % tilesetColumns) * tileWidth;
const srcY = Math.floor((tileIndex - 1) / tilesetColumns) * tileHeight;
// Only draw if tile is within tileset bounds
if (srcX + tileWidth <= tilesetImage.width && srcY + tileHeight <= tilesetImage.height) {
ctx.drawImage(
tilesetImage,
srcX, srcY, tileWidth, tileHeight,
x * tileWidth, y * tileHeight, tileWidth, tileHeight
);
}
}
}
}
// Restore opacity
ctx.globalAlpha = savedAlpha;
}
}
// Draw collision overlay
if (showCollision) {
ctx.fillStyle = 'rgba(255, 0, 0, 0.3)';
for (let y = 0; y < tilemap.height; y++) {
for (let x = 0; x < tilemap.width; x++) {
if (tilemap.hasCollision(x, y)) {
ctx.fillRect(x * tileWidth, y * tileHeight, tileWidth, tileHeight);
}
}
}
}
// Draw grid
if (showGrid) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.lineWidth = 1 / zoom;
for (let x = 0; x <= tilemap.width; x++) {
ctx.beginPath();
ctx.moveTo(x * tileWidth, 0);
ctx.lineTo(x * tileWidth, canvasHeight);
ctx.stroke();
}
for (let y = 0; y <= tilemap.height; y++) {
ctx.beginPath();
ctx.moveTo(0, y * tileHeight);
ctx.lineTo(canvasWidth, y * tileHeight);
ctx.stroke();
}
}
// Draw tool preview
if (mousePos && tools[currentTool]?.getPreviewTiles) {
const tool = tools[currentTool];
const toolContext: ToolContext = {
tilemap,
selectedTiles,
currentLayer,
layerLocked,
brushSize,
editingCollision,
tileWidth,
tileHeight,
};
const previewTiles = tool.getPreviewTiles!(mousePos.tileX, mousePos.tileY, toolContext);
ctx.fillStyle = editingCollision ? 'rgba(255, 0, 0, 0.3)' : 'rgba(0, 120, 212, 0.3)';
for (const tile of previewTiles) {
if (tile.x >= 0 && tile.x < tilemap.width && tile.y >= 0 && tile.y < tilemap.height) {
ctx.fillRect(tile.x * tileWidth, tile.y * tileHeight, tileWidth, tileHeight);
}
}
}
ctx.restore();
}, [tilemap, tilesetImage, zoom, panX, panY, showGrid, showCollision, mousePos, currentTool, selectedTiles, brushSize, currentLayer, layerLocked, editingCollision, tileWidth, tileHeight, tilesetColumns, canvasWidth, canvasHeight, layersKey]);
// Update canvas size
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const resizeObserver = new ResizeObserver(() => {
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
draw();
});
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, [draw]);
useEffect(() => {
draw();
}, [draw]);
// 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 or space+left click for panning
if (e.button === 1 || (e.button === 0 && e.altKey)) {
setIsPanning(true);
setLastPanPos({ x: e.clientX, y: e.clientY });
return;
}
// Save undo state
const layerData = tilemap.getLayerData(currentLayer);
if (layerData) {
pushUndo(layerData.slice());
}
const { tileX, tileY } = screenToTile(x, y);
const tool = tools[currentTool];
if (tool) {
const toolContext: ToolContext = {
tilemap,
selectedTiles,
currentLayer,
layerLocked,
brushSize,
editingCollision,
tileWidth,
tileHeight,
};
tool.onMouseDown(tileX, tileY, toolContext);
onTilemapChange?.();
draw();
}
};
const handleMouseMove = (e: React.MouseEvent) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Handle panning
if (isPanning) {
const dx = e.clientX - lastPanPos.x;
const dy = e.clientY - lastPanPos.y;
setPan(panX + dx, panY + dy);
setLastPanPos({ x: e.clientX, y: e.clientY });
return;
}
const { tileX, tileY } = screenToTile(x, y);
setMousePos({ tileX, tileY });
// Handle tool drag
if (e.buttons === 1) {
const tool = tools[currentTool];
if (tool) {
const toolContext: ToolContext = {
tilemap,
selectedTiles,
currentLayer,
layerLocked,
brushSize,
editingCollision,
tileWidth,
tileHeight,
};
tool.onMouseMove(tileX, tileY, toolContext);
onTilemapChange?.();
}
}
draw();
};
const handleMouseUp = (e: React.MouseEvent) => {
if (isPanning) {
setIsPanning(false);
return;
}
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const { tileX, tileY } = screenToTile(x, y);
const tool = tools[currentTool];
if (tool) {
const toolContext: ToolContext = {
tilemap,
selectedTiles,
currentLayer,
layerLocked,
brushSize,
editingCollision,
tileWidth,
tileHeight,
};
tool.onMouseUp(tileX, tileY, toolContext);
}
};
const handleMouseLeave = () => {
setMousePos(null);
draw();
};
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.max(0.1, Math.min(10, zoom * delta));
// Zoom towards mouse position
const rect = canvasRef.current?.getBoundingClientRect();
if (rect) {
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const newPanX = mouseX - (mouseX - panX) * (newZoom / zoom);
const newPanY = mouseY - (mouseY - panY) * (newZoom / zoom);
setPan(newPanX, newPanY);
}
setZoom(newZoom);
};
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: isPanning ? 'grabbing' : tools[currentTool]?.cursor || 'default' }}
/>
</div>
);
};

View File

@@ -0,0 +1,210 @@
/**
* Tileset Preview Component - Display and select tiles from a tileset
*/
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { useTilemapEditorStore, type TileSelection } from '../stores/TilemapEditorStore';
interface TilesetPreviewProps {
imageUrl: string;
tileWidth: number;
tileHeight: number;
columns: number;
rows: number;
onSelectionChange?: (selection: TileSelection) => void;
}
export const TilesetPreview: React.FC<TilesetPreviewProps> = ({
imageUrl,
tileWidth,
tileHeight,
columns,
rows,
onSelectionChange,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [image, setImage] = useState<HTMLImageElement | null>(null);
const [isSelecting, setIsSelecting] = useState(false);
const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
const [selectionEnd, setSelectionEnd] = useState<{ x: number; y: number } | null>(null);
const [zoom, setZoom] = useState(1);
const selectedTiles = useTilemapEditorStore(state => state.selectedTiles);
const setSelectedTiles = useTilemapEditorStore(state => state.setSelectedTiles);
// Load image
useEffect(() => {
const img = new Image();
img.onload = () => setImage(img);
img.src = imageUrl;
}, [imageUrl]);
// Draw tileset
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas || !image) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set canvas size based on actual image size (+1 for border lines)
canvas.width = image.width + 1;
canvas.height = image.height + 1;
// Draw image
ctx.imageSmoothingEnabled = false;
ctx.drawImage(image, 0, 0);
// Draw grid only within the actual tileset area
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
ctx.lineWidth = 1;
for (let x = 0; x <= columns; x++) {
ctx.beginPath();
ctx.moveTo(x * tileWidth + 0.5, 0);
ctx.lineTo(x * tileWidth + 0.5, image.height);
ctx.stroke();
}
for (let y = 0; y <= rows; y++) {
ctx.beginPath();
ctx.moveTo(0, y * tileHeight + 0.5);
ctx.lineTo(image.width, y * tileHeight + 0.5);
ctx.stroke();
}
// Draw selection preview during drag
if (isSelecting && selectionStart && selectionEnd) {
const minX = Math.min(selectionStart.x, selectionEnd.x);
const maxX = Math.max(selectionStart.x, selectionEnd.x);
const minY = Math.min(selectionStart.y, selectionEnd.y);
const maxY = Math.max(selectionStart.y, selectionEnd.y);
ctx.fillStyle = 'rgba(0, 120, 212, 0.3)';
ctx.fillRect(
minX * tileWidth,
minY * tileHeight,
(maxX - minX + 1) * tileWidth,
(maxY - minY + 1) * tileHeight
);
}
// Draw current selection
if (selectedTiles && !isSelecting) {
ctx.strokeStyle = '#0078d4';
ctx.lineWidth = 2;
ctx.strokeRect(
selectedTiles.x * tileWidth + 1,
selectedTiles.y * tileHeight + 1,
selectedTiles.width * tileWidth - 2,
selectedTiles.height * tileHeight - 2
);
}
}, [image, columns, rows, tileWidth, tileHeight, selectedTiles, isSelecting, selectionStart, selectionEnd]);
useEffect(() => {
draw();
}, [draw]);
const getTileCoords = (e: React.MouseEvent): { x: number; y: number } => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = Math.floor((e.clientX - rect.left) * scaleX / tileWidth);
const y = Math.floor((e.clientY - rect.top) * scaleY / tileHeight);
return {
x: Math.max(0, Math.min(columns - 1, x)),
y: Math.max(0, Math.min(rows - 1, y)),
};
};
const handleWheel = (e: React.WheelEvent) => {
// Only zoom when Ctrl is pressed
if (e.ctrlKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
setZoom(z => Math.max(0.5, Math.min(5, z * delta)));
}
// Otherwise let the default scroll behavior work
};
const handleMouseDown = (e: React.MouseEvent) => {
const coords = getTileCoords(e);
setIsSelecting(true);
setSelectionStart(coords);
setSelectionEnd(coords);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isSelecting) return;
const coords = getTileCoords(e);
setSelectionEnd(coords);
};
const handleMouseUp = () => {
if (!isSelecting || !selectionStart || !selectionEnd) {
setIsSelecting(false);
return;
}
const minX = Math.min(selectionStart.x, selectionEnd.x);
const maxX = Math.max(selectionStart.x, selectionEnd.x);
const minY = Math.min(selectionStart.y, selectionEnd.y);
const maxY = Math.max(selectionStart.y, selectionEnd.y);
const width = maxX - minX + 1;
const height = maxY - minY + 1;
const tiles: number[] = [];
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
// Tile index = y * columns + x + 1 (0 is empty)
tiles.push(y * columns + x + 1);
}
}
const selection: TileSelection = {
x: minX,
y: minY,
width,
height,
tiles,
};
setSelectedTiles(selection);
onSelectionChange?.(selection);
setIsSelecting(false);
setSelectionStart(null);
setSelectionEnd(null);
};
return (
<div
ref={containerRef}
style={{
width: '100%',
height: '100%',
}}
onWheel={handleWheel}
>
<canvas
ref={canvasRef}
className="tileset-canvas"
style={{
transform: `scale(${zoom})`,
transformOrigin: 'top left',
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
</div>
);
};

View File

@@ -0,0 +1,230 @@
/**
* Layer Panel Component
* 图层面板组件
*/
import React, { useState, useCallback } from 'react';
import { Eye, EyeOff, Lock, Unlock, Plus, Trash2, ChevronUp, ChevronDown, Paintbrush } from 'lucide-react';
import { useTilemapEditorStore, type LayerState } from '../../stores/TilemapEditorStore';
import type { TilemapComponent } from '../../../TilemapComponent';
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,
} = 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">
{layers.map((layer, index) => (
<div
key={layer.id}
className={`layer-item ${index === currentLayer ? 'selected' : ''} ${layer.locked ? 'locked' : ''}`}
onClick={() => 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;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
/**
* Tileset Panel - Display tileset for selection
*/
import React, { useEffect, useCallback } from 'react';
import { Core } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { TilemapComponent, type ITilesetData } from '../../../TilemapComponent';
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>
);
};

View File

@@ -0,0 +1,119 @@
/**
* 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/ecs-components';
import { TilemapComponent } from '../../TilemapComponent';
/**
* 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;
}
/**
* 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);
}
/**
* Unregister gizmo provider for TilemapComponent.
* 取消注册 TilemapComponent 的 gizmo 提供者。
*/
export function unregisterTilemapGizmo(): void {
GizmoRegistry.unregister(TilemapComponent);
}

View File

@@ -0,0 +1,244 @@
/**
* Tilemap 编辑器模块入口
* Tilemap Editor Module Entry
*/
import React from 'react';
import type { ServiceContainer, Entity } from '@esengine/ecs-framework';
import { Core } from '@esengine/ecs-framework';
import type {
IEditorModuleLoader,
PanelDescriptor,
EntityCreationTemplate,
ComponentAction,
ComponentInspectorProviderDef,
GizmoProviderRegistration
} from '@esengine/editor-core';
import {
PanelPosition,
InspectorRegistry,
EntityStoreService,
MessageHub,
ComponentRegistry,
IDialogService,
IFileSystemService
} from '@esengine/editor-core';
import type { IDialog, IFileSystem } from '@esengine/editor-core';
import { TransformComponent } from '@esengine/ecs-components';
// Local imports
import { TilemapComponent } from '../TilemapComponent';
import { TilemapEditorPanel } from './components/panels/TilemapEditorPanel';
import { TilemapInspectorProvider } from './providers/TilemapInspectorProvider';
import { registerTilemapGizmo } from './gizmos/TilemapGizmo';
import { useTilemapEditorStore } from './stores/TilemapEditorStore';
// Re-exports
export { TilemapEditorPanel } from './components/panels/TilemapEditorPanel';
export { TilesetPanel } from './components/panels/TilesetPanel';
export { TilemapCanvas } from './components/TilemapCanvas';
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 { TilemapInspectorProvider } from './providers/TilemapInspectorProvider';
/**
* 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(ComponentRegistry);
if (componentRegistry) {
componentRegistry.register({
name: 'Tilemap',
type: TilemapComponent,
category: 'components.category.tilemap',
description: 'Tilemap component for tile-based levels',
icon: 'Grid3X3'
});
}
// 订阅 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);
}
// 注册 Tilemap Gizmo | Register Tilemap gizmo
registerTilemapGizmo();
}
async uninstall(): Promise<void> {
this.unsubscribers.forEach(unsub => unsub());
this.unsubscribers = [];
}
getPanels(): PanelDescriptor[] {
return [
{
id: 'tilemap-editor',
title: 'Tilemap Editor',
position: PanelPosition.Center,
render: () => React.createElement(TilemapEditorPanel),
},
];
}
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[] {
return [
{
id: 'tilemap-edit',
componentName: 'Tilemap',
label: '编辑 Tilemap',
icon: 'Edit3',
execute: (_component: unknown, entity: Entity) => {
const messageHub = Core.services.resolve(MessageHub);
if (messageHub) {
const entityIdStr = String(entity.id);
useTilemapEditorStore.getState().setEntityId(entityIdStr);
messageHub.publish('dynamic-panel:open', { panelId: 'tilemap-editor', title: 'Tilemap Editor' });
}
}
}
];
}
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.json'] }],
defaultPath: 'new-tilemap.tilemap.json'
});
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();
// Plugin exports
export { TilemapPlugin, TilemapRuntimeModule } from './TilemapPlugin';
export default tilemapEditorModule;

View File

@@ -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 '../../TilemapComponent';
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>
);
}
}

View File

@@ -0,0 +1,210 @@
/**
* 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;
}
export interface TilemapEditorState {
// Current editing target
entityId: 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;
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;
renameLayer: (index: number, name: string) => void;
}
const initialState = {
entityId: 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 }),
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 });
},
renameLayer: (index, name) => {
const { layers } = get();
const layer = layers[index];
if (!layer) return;
const newLayers = [...layers];
newLayers[index] = { ...layer, name };
set({ layers: newLayers });
},
}));

View File

@@ -0,0 +1,741 @@
/* Tilemap Editor Styles */
.tilemap-editor-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--panel-bg, #1e1e1e);
color: var(--text-primary, #e0e0e0);
}
.tilemap-editor-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-bottom: 1px solid var(--border-color, #3c3c3c);
background: var(--toolbar-bg, #252526);
}
.tilemap-editor-toolbar .tool-group {
display: flex;
gap: 2px;
}
.tilemap-editor-toolbar .tool-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-secondary, #999);
cursor: pointer;
transition: all 0.15s ease;
}
.tilemap-editor-toolbar .tool-btn:hover {
background: var(--hover-bg, #3c3c3c);
color: var(--text-primary, #e0e0e0);
}
.tilemap-editor-toolbar .tool-btn.active {
background: var(--accent-color, #0078d4);
color: white;
}
.tilemap-editor-toolbar .separator {
width: 1px;
height: 20px;
background: var(--border-color, #3c3c3c);
margin: 0 4px;
}
.tilemap-editor-toolbar .zoom-control {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.tilemap-editor-toolbar .zoom-control span {
font-size: 12px;
min-width: 40px;
text-align: center;
}
/* Main content layout */
.tilemap-editor-content {
display: flex;
flex: 1;
overflow: hidden;
}
.tilemap-canvas-container {
flex: 1;
overflow: hidden;
position: relative;
}
.tilemap-canvas {
position: absolute;
top: 0;
left: 0;
}
/* Sidebar */
.tilemap-editor-sidebar {
position: relative;
width: 220px;
display: flex;
flex-direction: column;
border-left: 1px solid var(--border-color, #3c3c3c);
background: var(--panel-bg, #1e1e1e);
overflow-y: auto;
}
.sidebar-resize-handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: ew-resize;
background: transparent;
transition: background 0.15s ease;
z-index: 10;
}
.sidebar-resize-handle:hover,
.sidebar-resize-handle.active {
background: var(--accent-color, #0078d4);
}
/* Section styles */
.tileset-section {
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--border-color, #3c3c3c);
}
.section-header {
display: flex;
align-items: center;
gap: 4px;
padding: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.section-header:hover {
background: var(--hover-bg, #2a2a2a);
}
.section-header span {
flex: 1;
}
.section-actions {
display: flex;
gap: 2px;
}
.section-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: 3px;
background: transparent;
color: var(--text-secondary, #999);
cursor: pointer;
}
.section-btn:hover {
background: var(--hover-bg, #3c3c3c);
color: var(--text-primary, #e0e0e0);
}
.tileset-content {
padding: 8px;
overflow: auto;
}
.tileset-info {
padding: 4px 0;
font-size: 11px;
color: var(--text-secondary, #999);
text-align: center;
}
.tileset-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px;
color: var(--text-secondary, #999);
font-size: 12px;
}
.tileset-empty button {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border: none;
border-radius: 4px;
background: var(--accent-color, #0078d4);
color: white;
font-size: 12px;
cursor: pointer;
}
.tileset-empty button:hover {
background: var(--accent-hover, #106ebe);
}
.tileset-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 8px;
color: var(--text-secondary, #999);
font-size: 12px;
}
.tileset-empty-state button {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border: none;
border-radius: 4px;
background: var(--accent-color, #0078d4);
color: white;
font-size: 12px;
cursor: pointer;
}
.tileset-empty-state button:hover {
background: var(--accent-hover, #106ebe);
}
/* Sidebar toggle button */
.sidebar-toggle {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: var(--toolbar-bg, #252526);
color: var(--text-secondary, #999);
cursor: pointer;
transition: all 0.15s ease;
}
.sidebar-toggle:hover {
background: var(--hover-bg, #3c3c3c);
color: var(--text-primary, #e0e0e0);
}
/* Resize handle */
.resize-handle {
height: 4px;
cursor: ns-resize;
background: transparent;
transition: background 0.15s ease;
}
.resize-handle:hover,
.resize-handle.active {
background: var(--accent-color, #0078d4);
}
/* Tileset Panel */
.tileset-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--panel-bg, #1e1e1e);
}
.tileset-panel-header {
padding: 8px;
border-bottom: 1px solid var(--border-color, #3c3c3c);
}
.tileset-panel-header h3 {
margin: 0;
font-size: 12px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
}
.tileset-canvas-container {
flex: 1;
overflow: auto;
padding: 8px;
}
.tileset-canvas {
cursor: crosshair;
image-rendering: pixelated;
}
.tileset-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary, #999);
font-size: 12px;
text-align: center;
padding: 16px;
}
/* Info bar */
.tilemap-info-bar {
display: flex;
align-items: center;
gap: 16px;
padding: 4px 8px;
font-size: 11px;
color: var(--text-secondary, #999);
background: var(--toolbar-bg, #252526);
border-top: 1px solid var(--border-color, #3c3c3c);
}
.tilemap-info-bar span {
display: flex;
align-items: center;
gap: 4px;
}
/* Empty state */
.tilemap-editor-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary, #999);
text-align: center;
padding: 32px;
}
.tilemap-editor-empty svg {
margin-bottom: 16px;
opacity: 0.5;
}
.tilemap-editor-empty h3 {
margin: 0 0 8px;
font-size: 14px;
color: var(--text-primary, #e0e0e0);
}
.tilemap-editor-empty p {
margin: 0;
font-size: 12px;
}
/* Layer Panel */
.layer-panel {
display: flex;
flex-direction: column;
background: var(--panel-bg, #1e1e1e);
border-top: 1px solid var(--border-color, #3c3c3c);
}
.layer-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
font-size: 12px;
font-weight: 600;
border-bottom: 1px solid var(--border-color, #3c3c3c);
}
.layer-panel-actions {
display: flex;
gap: 4px;
}
.layer-panel-empty {
padding: 16px;
text-align: center;
color: var(--text-secondary, #999);
font-size: 12px;
}
.layer-list {
flex: 1;
overflow-y: auto;
max-height: 200px;
}
.layer-item {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
cursor: pointer;
border-bottom: 1px solid var(--border-color, #2d2d2d);
transition: background 0.15s ease;
}
.layer-item:hover {
background: var(--hover-bg, #2a2a2a);
}
.layer-item.selected {
background: var(--selection-bg, #094771);
}
.layer-item.locked {
opacity: 0.6;
}
.layer-controls {
display: flex;
gap: 2px;
}
.layer-info {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.layer-active-indicator {
display: inline-flex;
align-items: center;
color: var(--accent-color, #0078d4);
flex-shrink: 0;
}
.layer-name {
display: block;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.layer-name-input {
width: 100%;
padding: 2px 4px;
font-size: 12px;
border: 1px solid var(--accent-color, #0078d4);
border-radius: 2px;
background: var(--input-bg, #3c3c3c);
color: var(--text-primary, #e0e0e0);
outline: none;
}
.layer-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.layer-item:hover .layer-actions {
opacity: 1;
}
.layer-opacity-control {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-top: 1px solid var(--border-color, #3c3c3c);
font-size: 11px;
}
.layer-opacity-control label {
color: var(--text-secondary, #999);
white-space: nowrap;
min-width: 50px;
}
.layer-opacity-control input[type="range"] {
flex: 1;
height: 4px;
min-width: 60px;
}
.layer-opacity-control span {
min-width: 32px;
text-align: right;
color: var(--text-secondary, #999);
white-space: nowrap;
}
/* Icon buttons */
.icon-button {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-secondary, #999);
cursor: pointer;
transition: all 0.15s ease;
}
.icon-button:hover {
background: var(--hover-bg, #3c3c3c);
color: var(--text-primary, #e0e0e0);
}
.icon-button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.icon-button.small {
width: 20px;
height: 20px;
}
.icon-button.danger:hover {
background: var(--error-bg, #5a1d1d);
color: var(--error-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: var(--panel-bg, #1e1e1e);
border: 1px solid var(--border-color, #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 var(--border-color, #3c3c3c);
}
.asset-picker-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
}
.asset-picker-close {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-secondary, #999);
cursor: pointer;
}
.asset-picker-close:hover {
background: var(--hover-bg, #3c3c3c);
color: var(--text-primary, #e0e0e0);
}
.asset-picker-search {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid var(--border-color, #3c3c3c);
}
.asset-picker-search input {
flex: 1;
border: none;
background: transparent;
color: var(--text-primary, #e0e0e0);
font-size: 13px;
outline: none;
}
.asset-picker-search input::placeholder {
color: var(--text-secondary, #999);
}
.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: var(--text-primary, #e0e0e0);
}
.asset-picker-item:hover {
background: var(--hover-bg, #2a2a2a);
}
.asset-picker-item.selected {
background: var(--selection-bg, #094771);
}
.asset-picker-item-icon {
display: flex;
align-items: center;
color: var(--text-secondary, #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: var(--text-secondary, #999);
font-size: 13px;
}
.asset-picker-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-top: 1px solid var(--border-color, #3c3c3c);
}
.asset-picker-selected {
flex: 1;
font-size: 12px;
color: var(--text-secondary, #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: var(--hover-bg, #3c3c3c);
color: var(--text-primary, #e0e0e0);
}
.asset-picker-actions .btn-cancel:hover {
background: var(--border-color, #4c4c4c);
}
.asset-picker-actions .btn-confirm {
background: var(--accent-color, #0078d4);
color: white;
}
.asset-picker-actions .btn-confirm:hover:not(:disabled) {
background: var(--accent-hover, #106ebe);
}
.asset-picker-actions .btn-confirm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Image Preview Tooltip */
.asset-picker-preview {
position: fixed;
z-index: 1001;
background: var(--panel-bg, #1e1e1e);
border: 1px solid var(--border-color, #3c3c3c);
border-radius: 4px;
padding: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
pointer-events: none;
min-width: 100px;
min-height: 100px;
display: flex;
align-items: center;
justify-content: center;
}
.asset-picker-preview img {
display: block;
max-width: 150px;
max-height: 150px;
width: auto;
height: auto;
image-rendering: pixelated;
}

View 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;
}
}
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -0,0 +1,79 @@
/**
* 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
}
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]);
}
}
}
}

View File

@@ -0,0 +1,31 @@
/**
* Tilemap Tool Interface
*/
import type { TilemapComponent } from '../../TilemapComponent';
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 }[];
}

View File

@@ -4,14 +4,26 @@
*/
// Component
export { TilemapComponent, ITilemapData, ITilesetData, ResizeAnchor } from './TilemapComponent';
export { TilemapComponent } from './TilemapComponent';
export type { ITilemapData, ITilesetData } from './TilemapComponent';
export type { ResizeAnchor } from './TilemapComponent';
// Systems
export { TilemapRenderingSystem, TilemapRenderData, ViewportBounds } from './systems/TilemapRenderingSystem';
export { TilemapRenderingSystem } from './systems/TilemapRenderingSystem';
export type { TilemapRenderData, ViewportBounds } from './systems/TilemapRenderingSystem';
// Loaders
export { TilemapLoader, ITilemapAsset } from './loaders/TilemapLoader';
export { TilesetLoader, ITilesetAsset } from './loaders/TilesetLoader';
export { TilemapLoader } from './loaders/TilemapLoader';
export type { ITilemapAsset } from './loaders/TilemapLoader';
export { TilesetLoader } from './loaders/TilesetLoader';
export type { ITilesetAsset } from './loaders/TilesetLoader';
// Tiled converter
export { TiledConverter, ITiledMap, ITiledConversionResult } from './loaders/TiledConverter';
export { TiledConverter } from './loaders/TiledConverter';
export type { ITiledMap, ITiledConversionResult } from './loaders/TiledConverter';
// Runtime module (no editor dependencies)
export { TilemapRuntimeModule } from './TilemapRuntimeModule';
// Plugin (for PluginManager - includes editor dependencies)
export { TilemapPlugin } from './editor/TilemapPlugin';

View File

@@ -0,0 +1,31 @@
/**
* @esengine/tilemap Runtime Entry Point
*
* This entry point exports only runtime-related code without any editor dependencies.
* Use this for standalone game runtime builds.
*
* 此入口点仅导出运行时相关代码,不包含任何编辑器依赖。
* 用于独立游戏运行时构建。
*/
// Component
export { TilemapComponent } from './TilemapComponent';
export type { ITilemapData, ITilesetData } from './TilemapComponent';
export type { ResizeAnchor } from './TilemapComponent';
// Systems
export { TilemapRenderingSystem } from './systems/TilemapRenderingSystem';
export type { TilemapRenderData, ViewportBounds } from './systems/TilemapRenderingSystem';
// Loaders
export { TilemapLoader } from './loaders/TilemapLoader';
export type { ITilemapAsset } from './loaders/TilemapLoader';
export { TilesetLoader } from './loaders/TilesetLoader';
export type { ITilesetAsset } from './loaders/TilesetLoader';
// Tiled converter
export { TiledConverter } from './loaders/TiledConverter';
export type { ITiledMap, ITiledConversionResult } from './loaders/TiledConverter';
// Runtime module
export { TilemapRuntimeModule } from './TilemapRuntimeModule';

View File

@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "node",
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"lib": ["ES2020", "DOM"],
"outDir": "./bin",
@@ -32,10 +32,12 @@
"downlevelIteration": true,
"isolatedModules": false,
"allowJs": true,
"resolveJsonModule": true
"resolveJsonModule": true,
"jsx": "react-jsx"
},
"include": [
"src/**/*"
"src/**/*",
"plugin.json"
],
"exclude": [
"node_modules",
@@ -52,6 +54,9 @@
},
{
"path": "../ecs-engine-bindgen"
},
{
"path": "../editor-core"
}
]
}

View File

@@ -0,0 +1,100 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
import react from '@vitejs/plugin-react';
// 自定义插件:将 CSS 内联到 JS 中
function inlineCSS(): any {
return {
name: 'inline-css',
enforce: 'post' as const,
// 在生成 bundle 时注入 CSS
generateBundle(_options: any, bundle: any) {
const bundleKeys = Object.keys(bundle);
// 找到 CSS 文件
const cssFile = bundleKeys.find(key => key.endsWith('.css'));
if (!cssFile || !bundle[cssFile]) {
return;
}
const cssContent = bundle[cssFile].source;
if (!cssContent) return;
// 找到包含编辑器代码的主要 JS 文件(带 hash 的 chunk
const mainJsFile = bundleKeys.find(key =>
key.endsWith('.js') &&
key.includes('index-') &&
bundle[key].type === 'chunk' &&
bundle[key].code
);
if (mainJsFile && bundle[mainJsFile]) {
const injectCode = `
(function() {
if (typeof document !== 'undefined') {
var style = document.createElement('style');
style.id = 'esengine-tilemap-styles';
if (!document.getElementById(style.id)) {
style.textContent = ${JSON.stringify(cssContent)};
document.head.appendChild(style);
}
}
})();
`;
bundle[mainJsFile].code = injectCode + bundle[mainJsFile].code;
}
// 删除独立的 CSS 文件(已内联)
delete bundle[cssFile];
}
};
}
export default defineConfig({
plugins: [
react(),
dts({
include: ['src'],
outDir: 'dist',
rollupTypes: false
}),
inlineCSS()
],
esbuild: {
jsx: 'automatic',
},
build: {
lib: {
entry: {
index: resolve(__dirname, 'src/index.ts'),
runtime: resolve(__dirname, 'src/runtime.ts'),
'editor/index': resolve(__dirname, 'src/editor/index.ts')
},
formats: ['es'],
fileName: (format, entryName) => `${entryName}.js`
},
rollupOptions: {
external: [
'@esengine/ecs-framework',
'@esengine/ecs-components',
'@esengine/ecs-engine-bindgen',
'@esengine/asset-system',
'@esengine/editor-core',
'react',
'react/jsx-runtime',
'lucide-react',
'zustand',
/^@esengine\//,
/^@tauri-apps\//
],
output: {
exports: 'named',
preserveModules: false
}
},
target: 'es2020',
minify: false,
sourcemap: true
}
});