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:
@@ -1,233 +0,0 @@
|
||||
/**
|
||||
* Tilemap Editor Plugin
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Grid3X3 } from 'lucide-react';
|
||||
import type { ServiceContainer, Entity } from '@esengine/ecs-framework';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IEditorPlugin,
|
||||
PanelDescriptor,
|
||||
EntityCreationTemplate,
|
||||
ComponentAction,
|
||||
} from '@esengine/editor-core';
|
||||
import { EditorPluginCategory, PanelPosition, InspectorRegistry, EntityStoreService, MessageHub, ComponentRegistry, IDialogService, IFileSystemService } from '@esengine/editor-core';
|
||||
import type { IDialog, IFileSystem } from '@esengine/editor-core';
|
||||
import { Edit3 } from 'lucide-react';
|
||||
import { TilemapComponent } from '@esengine/tilemap';
|
||||
import { TransformComponent } from '@esengine/ecs-components';
|
||||
import { useTilemapEditorStore } from './stores/TilemapEditorStore';
|
||||
import { TilemapEditorPanel } from './components/panels/TilemapEditorPanel';
|
||||
import { TilemapInspectorProvider } from './providers/TilemapInspectorProvider';
|
||||
import { registerTilemapGizmo } from './gizmos/TilemapGizmo';
|
||||
|
||||
export class TilemapEditorPlugin implements IEditorPlugin {
|
||||
readonly name = '@esengine/tilemap-editor';
|
||||
readonly version = '1.0.0';
|
||||
readonly category = EditorPluginCategory.Tool;
|
||||
|
||||
private unsubscribers: Array<() => void> = [];
|
||||
|
||||
get displayName(): string {
|
||||
return 'Tilemap Editor';
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return 'Visual tilemap editing tools for creating tile-based game levels';
|
||||
}
|
||||
|
||||
registerPanels(): PanelDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'tilemap-editor',
|
||||
title: 'Tilemap Editor',
|
||||
position: PanelPosition.Center,
|
||||
component: TilemapEditorPanel,
|
||||
isDynamic: true,
|
||||
closable: true,
|
||||
order: 50,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async install(_core: Core, services: ServiceContainer): Promise<void> {
|
||||
// Register inspector provider
|
||||
const inspectorRegistry = services.resolve(InspectorRegistry);
|
||||
if (inspectorRegistry) {
|
||||
inspectorRegistry.register(new TilemapInspectorProvider());
|
||||
}
|
||||
|
||||
// Register TilemapComponent to component registry for add component menu
|
||||
const componentRegistry = services.resolve(ComponentRegistry);
|
||||
if (componentRegistry) {
|
||||
componentRegistry.register({
|
||||
name: 'Tilemap',
|
||||
type: TilemapComponent,
|
||||
category: 'components.category.tilemap',
|
||||
description: 'Tilemap component for tile-based levels'
|
||||
});
|
||||
}
|
||||
|
||||
// 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 Tilemap gizmo support
|
||||
// 注册 Tilemap gizmo 支持
|
||||
registerTilemapGizmo();
|
||||
|
||||
console.log('[TilemapEditorPlugin] Installed');
|
||||
}
|
||||
|
||||
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('[TilemapEditorPlugin] Dialog or FileSystem service not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show save dialog
|
||||
const filePath = await dialog.saveDialog({
|
||||
title: '创建 Tilemap 资产',
|
||||
filters: [{ name: 'Tilemap', extensions: ['tilemap.json'] }],
|
||||
defaultPath: 'new-tilemap.tilemap.json'
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create default tilemap data
|
||||
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: []
|
||||
};
|
||||
|
||||
// Write file
|
||||
await fileSystem.writeFile(filePath, JSON.stringify(defaultTilemapData, null, 2));
|
||||
|
||||
// Update component property via onChange callback
|
||||
if (payload.onChange) {
|
||||
payload.onChange(filePath);
|
||||
}
|
||||
|
||||
// Open tilemap editor panels
|
||||
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' });
|
||||
}
|
||||
|
||||
console.log('[TilemapEditorPlugin] Created tilemap asset:', filePath);
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
// Cleanup subscriptions
|
||||
this.unsubscribers.forEach(unsub => unsub());
|
||||
this.unsubscribers = [];
|
||||
|
||||
console.log('[TilemapEditorPlugin] Uninstalled');
|
||||
}
|
||||
|
||||
registerComponentActions(): ComponentAction[] {
|
||||
return [
|
||||
{
|
||||
id: 'tilemap-edit',
|
||||
componentName: 'Tilemap',
|
||||
label: '编辑 Tilemap',
|
||||
icon: React.createElement(Edit3, { size: 14 }),
|
||||
order: 0,
|
||||
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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
registerEntityCreationTemplates(): EntityCreationTemplate[] {
|
||||
return [
|
||||
{
|
||||
id: 'create-tilemap-entity',
|
||||
label: '创建 Tilemap',
|
||||
icon: React.createElement(Grid3X3, { size: 12 }),
|
||||
order: 100,
|
||||
create: (_parentEntityId?: number): 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');
|
||||
}
|
||||
|
||||
// Count existing tilemap entities
|
||||
const tilemapCount = entityStore.getAllEntities()
|
||||
.filter((e: Entity) => e.name.startsWith('Tilemap ')).length;
|
||||
const entityName = `Tilemap ${tilemapCount + 1}`;
|
||||
|
||||
// Create entity via scene
|
||||
const entity = scene.createEntity(entityName);
|
||||
|
||||
// Add TransformComponent (required for rendering)
|
||||
entity.addComponent(new TransformComponent());
|
||||
|
||||
// Add TilemapComponent with default settings
|
||||
const tilemapComponent = new TilemapComponent();
|
||||
tilemapComponent.tileWidth = 16;
|
||||
tilemapComponent.tileHeight = 16;
|
||||
tilemapComponent.initializeEmpty(20, 15);
|
||||
entity.addComponent(tilemapComponent);
|
||||
|
||||
// Register with entity store
|
||||
entityStore.addEntity(entity);
|
||||
|
||||
// Notify
|
||||
messageHub.publish('entity:added', { entity });
|
||||
messageHub.publish('scene:modified', {});
|
||||
|
||||
// Select the new entity
|
||||
entityStore.selectEntity(entity);
|
||||
|
||||
return entity.id;
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export const tilemapEditorPlugin = new TilemapEditorPlugin();
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* Tilemap Editor Plugin Definition
|
||||
*/
|
||||
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import type { EditorPluginDefinition } from '@esengine/editor-core';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TilemapComponent } from '@esengine/tilemap';
|
||||
import { TransformComponent } from '@esengine/ecs-components';
|
||||
import { useTilemapEditorStore } from './stores/TilemapEditorStore';
|
||||
import { TilemapEditorPanel } from './components/panels/TilemapEditorPanel';
|
||||
|
||||
export const tilemapEditorPluginDefinition: EditorPluginDefinition = {
|
||||
id: '@esengine/tilemap-editor',
|
||||
name: 'Tilemap Editor',
|
||||
version: '1.0.0',
|
||||
description: 'Visual tilemap editing tools for creating tile-based game levels',
|
||||
|
||||
components: [
|
||||
{
|
||||
type: TilemapComponent,
|
||||
icon: 'grid-3x3',
|
||||
category: 'Tilemap',
|
||||
displayName: 'Tilemap',
|
||||
actions: [
|
||||
{
|
||||
id: 'tilemap-edit',
|
||||
label: '编辑 Tilemap',
|
||||
icon: 'edit-3',
|
||||
execute: (_componentData: unknown, entityId: number) => {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
useTilemapEditorStore.getState().setEntityId(String(entityId));
|
||||
messageHub.publish('dynamic-panel:open', { panelId: 'tilemap-editor', title: 'Tilemap Editor' });
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
panels: [
|
||||
{
|
||||
id: 'tilemap-editor',
|
||||
component: TilemapEditorPanel,
|
||||
title: 'Tilemap Editor',
|
||||
defaultPosition: 'bottom',
|
||||
defaultVisible: false,
|
||||
icon: 'grid-3x3'
|
||||
}
|
||||
],
|
||||
|
||||
entityTemplates: [
|
||||
{
|
||||
id: 'tilemap',
|
||||
label: 'Tilemap',
|
||||
category: '2D Object',
|
||||
icon: 'grid-3x3',
|
||||
priority: 100,
|
||||
create: (_parentEntityId?: 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;
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
assetHandlers: [
|
||||
{
|
||||
extensions: ['tilemap'],
|
||||
name: 'Tilemap Asset',
|
||||
icon: 'grid-3x3',
|
||||
onOpen: async (assetPath: string) => {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
// 打开 tilemap 编辑器面板
|
||||
messageHub.publish('dynamic-panel:open', {
|
||||
panelId: 'tilemap-editor',
|
||||
title: 'Tilemap Editor',
|
||||
data: { assetPath }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
onActivate: () => {
|
||||
console.log('[TilemapEditorPlugin] Activated');
|
||||
},
|
||||
|
||||
onDeactivate: () => {
|
||||
console.log('[TilemapEditorPlugin] Deactivated');
|
||||
}
|
||||
};
|
||||
@@ -1,364 +0,0 @@
|
||||
/**
|
||||
* Tilemap Canvas - Main editing canvas
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
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 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>
|
||||
);
|
||||
};
|
||||
@@ -1,210 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
@@ -1,230 +0,0 @@
|
||||
/**
|
||||
* 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 '@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,
|
||||
} = 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
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* 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 '@esengine/tilemap';
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* Tilemap Editor Package
|
||||
*/
|
||||
|
||||
// Plugin
|
||||
export { TilemapEditorPlugin, tilemapEditorPlugin } from './TilemapEditorPlugin';
|
||||
export { tilemapEditorPluginDefinition } from './TilemapEditorPluginDefinition';
|
||||
|
||||
// Components
|
||||
export { TilemapEditorPanel } from './components/panels/TilemapEditorPanel';
|
||||
export { TilesetPanel } from './components/panels/TilesetPanel';
|
||||
export { TilemapCanvas } from './components/TilemapCanvas';
|
||||
export { TilesetPreview } from './components/TilesetPreview';
|
||||
|
||||
// Store
|
||||
export { useTilemapEditorStore } from './stores/TilemapEditorStore';
|
||||
export type { TilemapEditorState, TilemapToolType, TileSelection } from './stores/TilemapEditorStore';
|
||||
|
||||
// Tools
|
||||
export type { ITilemapTool, ToolContext } from './tools/ITilemapTool';
|
||||
export { BrushTool } from './tools/BrushTool';
|
||||
export { EraserTool } from './tools/EraserTool';
|
||||
export { FillTool } from './tools/FillTool';
|
||||
|
||||
// Providers
|
||||
export { TilemapInspectorProvider } from './providers/TilemapInspectorProvider';
|
||||
|
||||
// Default export
|
||||
import { tilemapEditorPlugin as _plugin } from './TilemapEditorPlugin';
|
||||
export default _plugin;
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
/**
|
||||
* 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 });
|
||||
},
|
||||
}));
|
||||
@@ -1,741 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* 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 }[];
|
||||
}
|
||||
Reference in New Issue
Block a user