Feature/tilemap editor (#237)

* feat: 添加 Tilemap 编辑器插件和组件生命周期支持

* feat(editor-core): 添加声明式插件注册 API

* feat(editor-core): 改进tiledmap结构合并tileset进tiledmapeditor

* feat: 添加 editor-runtime SDK 和插件系统改进

* fix(ci): 修复SceneResourceManager里变量未使用问题
This commit is contained in:
YHH
2025-11-25 22:23:19 +08:00
committed by GitHub
parent 551ca7805d
commit 3fb6f919f8
166 changed files with 54691 additions and 8674 deletions

View File

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

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 '@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

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