Feature/physics and tilemap enhancement (#247)
* feat(behavior-tree,tilemap): 修复编辑器连线缩放问题并增强插件系统 * feat(node-editor,blueprint): 新增通用节点编辑器和蓝图可视化脚本系统 * feat(editor,tilemap): 优化编辑器UI样式和Tilemap编辑器功能 * fix: 修复CodeQL安全警告和CI类型检查错误 * fix: 修复CodeQL安全警告和CI类型检查错误 * fix: 修复CodeQL安全警告和CI类型检查错误
This commit is contained in:
@@ -124,7 +124,7 @@ export interface ITilemapData {
|
||||
export class TilemapComponent extends Component implements IResourceComponent {
|
||||
/** Tilemap asset GUID reference | 瓦片地图资源GUID引用 */
|
||||
@Serialize()
|
||||
@Property({ type: 'asset', label: 'Tilemap', extensions: ['.tilemap.json'] })
|
||||
@Property({ type: 'asset', label: 'Tilemap', extensions: ['.tilemap', '.tilemap.json'] })
|
||||
public tilemapAssetGuid: string = '';
|
||||
|
||||
@Serialize()
|
||||
|
||||
@@ -6,20 +6,38 @@
|
||||
import type { IScene } from '@esengine/ecs-framework';
|
||||
import { ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModuleLoader, SystemContext } from '@esengine/ecs-components';
|
||||
import type { AssetManager } from '@esengine/asset-system';
|
||||
|
||||
import { TilemapComponent } from './TilemapComponent';
|
||||
import { TilemapRenderingSystem } from './systems/TilemapRenderingSystem';
|
||||
import { TilemapCollider2DComponent } from './physics/TilemapCollider2DComponent';
|
||||
import { TilemapPhysicsSystem, type IPhysicsWorld } from './physics/TilemapPhysicsSystem';
|
||||
import { TilemapLoader } from './loaders/TilemapLoader';
|
||||
import { TilemapAssetType } from './index';
|
||||
|
||||
/**
|
||||
* Tilemap Runtime Module
|
||||
* Tilemap 运行时模块
|
||||
*/
|
||||
export class TilemapRuntimeModule implements IRuntimeModuleLoader {
|
||||
private _tilemapPhysicsSystem: TilemapPhysicsSystem | null = null;
|
||||
private _loaderRegistered = false;
|
||||
|
||||
registerComponents(registry: typeof ComponentRegistry): void {
|
||||
registry.register(TilemapComponent);
|
||||
registry.register(TilemapCollider2DComponent);
|
||||
}
|
||||
|
||||
createSystems(scene: IScene, context: SystemContext): void {
|
||||
// 注册 Tilemap 加载器到 AssetManager
|
||||
// Register tilemap loader to AssetManager
|
||||
const assetManager = context.assetManager as AssetManager | undefined;
|
||||
if (!this._loaderRegistered && assetManager) {
|
||||
assetManager.registerLoader(TilemapAssetType, new TilemapLoader());
|
||||
this._loaderRegistered = true;
|
||||
}
|
||||
|
||||
// Tilemap rendering system
|
||||
const tilemapSystem = new TilemapRenderingSystem();
|
||||
scene.addSystem(tilemapSystem);
|
||||
|
||||
@@ -28,5 +46,30 @@ export class TilemapRuntimeModule implements IRuntimeModuleLoader {
|
||||
}
|
||||
|
||||
context.tilemapSystem = tilemapSystem;
|
||||
|
||||
// Tilemap physics system
|
||||
this._tilemapPhysicsSystem = new TilemapPhysicsSystem();
|
||||
scene.addSystem(this._tilemapPhysicsSystem);
|
||||
|
||||
context.tilemapPhysicsSystem = this._tilemapPhysicsSystem;
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有系统创建完成后,连接跨插件依赖
|
||||
* Wire cross-plugin dependencies after all systems are created
|
||||
*/
|
||||
onSystemsCreated(_scene: IScene, context: SystemContext): void {
|
||||
// 连接物理世界(如果物理插件已加载)
|
||||
// Connect physics world (if physics plugin is loaded)
|
||||
if (this._tilemapPhysicsSystem && context.physics2DWorld) {
|
||||
this._tilemapPhysicsSystem.setPhysicsWorld(context.physics2DWorld as IPhysicsWorld);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Tilemap 物理系统
|
||||
*/
|
||||
get tilemapPhysicsSystem(): TilemapPhysicsSystem | null {
|
||||
return this._tilemapPhysicsSystem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,10 @@
|
||||
* Integrates runtime and editor modules
|
||||
*/
|
||||
|
||||
import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IPluginLoader,
|
||||
IRuntimeModuleLoader,
|
||||
PluginDescriptor,
|
||||
SystemContext
|
||||
} from '@esengine/editor-core';
|
||||
import type { IPluginLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
|
||||
// Runtime imports
|
||||
import { TilemapComponent } from '../TilemapComponent';
|
||||
import { TilemapRenderingSystem } from '../systems/TilemapRenderingSystem';
|
||||
// Runtime module
|
||||
import { TilemapRuntimeModule } from '../TilemapRuntimeModule';
|
||||
|
||||
// Editor imports
|
||||
import { TilemapEditorModule } from './index';
|
||||
@@ -49,33 +41,12 @@ const descriptor: PluginDescriptor = {
|
||||
}
|
||||
],
|
||||
dependencies: [
|
||||
{ id: '@esengine/core', version: '^1.0.0' }
|
||||
{ id: '@esengine/core', version: '^1.0.0' },
|
||||
{ id: '@esengine/physics-rapier2d', version: '^1.0.0', optional: true }
|
||||
],
|
||||
icon: 'Grid3X3'
|
||||
};
|
||||
|
||||
/**
|
||||
* Tilemap 运行时模块
|
||||
* Tilemap runtime module
|
||||
*/
|
||||
export class TilemapRuntimeModule implements IRuntimeModuleLoader {
|
||||
registerComponents(registry: typeof ComponentRegistry): void {
|
||||
registry.register(TilemapComponent);
|
||||
}
|
||||
|
||||
createSystems(scene: IScene, context: SystemContext): void {
|
||||
const tilemapSystem = new TilemapRenderingSystem();
|
||||
scene.addSystem(tilemapSystem);
|
||||
|
||||
if (context.renderSystem) {
|
||||
context.renderSystem.addRenderDataProvider(tilemapSystem);
|
||||
}
|
||||
|
||||
// 保存引用供其他系统使用 | Save reference for other systems
|
||||
context.tilemapSystem = tilemapSystem;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tilemap 插件加载器
|
||||
* Tilemap plugin loader
|
||||
|
||||
@@ -59,6 +59,7 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [lastPanPos, setLastPanPos] = useState({ x: 0, y: 0 });
|
||||
const [mousePos, setMousePos] = useState<{ tileX: number; tileY: number } | null>(null);
|
||||
const [spacePressed, setSpacePressed] = useState(false);
|
||||
|
||||
// Get canvas size
|
||||
const canvasWidth = tilemap.width * tileWidth;
|
||||
@@ -189,20 +190,83 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
let rafId: number | null = null;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
draw();
|
||||
// 使用 requestAnimationFrame 避免 ResizeObserver loop 错误
|
||||
// Use requestAnimationFrame to avoid ResizeObserver loop errors
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const newWidth = container.clientWidth;
|
||||
const newHeight = container.clientHeight;
|
||||
if (canvas.width !== newWidth || canvas.height !== newHeight) {
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
draw();
|
||||
}
|
||||
rafId = null;
|
||||
});
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
return () => resizeObserver.disconnect();
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [draw]);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
}, [draw]);
|
||||
|
||||
// Center view on first mount
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Only center if pan is at default position (0, 0)
|
||||
if (panX === 0 && panY === 0) {
|
||||
const containerWidth = container.clientWidth;
|
||||
const containerHeight = container.clientHeight;
|
||||
const mapPixelWidth = canvasWidth * zoom;
|
||||
const mapPixelHeight = canvasHeight * zoom;
|
||||
|
||||
const centerX = (containerWidth - mapPixelWidth) / 2;
|
||||
const centerY = (containerHeight - mapPixelHeight) / 2;
|
||||
|
||||
setPan(centerX, centerY);
|
||||
}
|
||||
}, []); // Only run on mount
|
||||
|
||||
// Space key for panning mode
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Space' && !e.repeat) {
|
||||
e.preventDefault();
|
||||
setSpacePressed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Space') {
|
||||
setSpacePressed(false);
|
||||
setIsPanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Convert screen coordinates to tile coordinates
|
||||
const screenToTile = useCallback((screenX: number, screenY: number) => {
|
||||
const x = (screenX - panX) / zoom;
|
||||
@@ -221,8 +285,8 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
||||
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)) {
|
||||
// Middle mouse button, Alt+left click, or Space+left click for panning
|
||||
if (e.button === 1 || (e.button === 0 && (e.altKey || spacePressed))) {
|
||||
setIsPanning(true);
|
||||
setLastPanPos({ x: e.clientX, y: e.clientY });
|
||||
return;
|
||||
@@ -346,6 +410,13 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
||||
setZoom(newZoom);
|
||||
};
|
||||
|
||||
// Determine cursor style
|
||||
const getCursor = () => {
|
||||
if (isPanning) return 'grabbing';
|
||||
if (spacePressed) return 'grab';
|
||||
return tools[currentTool]?.cursor || 'crosshair';
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="tilemap-canvas-container">
|
||||
<canvas
|
||||
@@ -357,7 +428,7 @@ export const TilemapCanvas: React.FC<TilemapCanvasProps> = ({
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
style={{ cursor: isPanning ? 'grabbing' : tools[currentTool]?.cursor || 'default' }}
|
||||
style={{ cursor: getCursor() }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Eye, EyeOff, Lock, Unlock, Plus, Trash2, ChevronUp, ChevronDown, Paintbrush } from 'lucide-react';
|
||||
import { Eye, EyeOff, Lock, Unlock, Plus, Trash2, ChevronUp, ChevronDown, Paintbrush, Shield, Grid3X3 } from 'lucide-react';
|
||||
import { useTilemapEditorStore, type LayerState } from '../../stores/TilemapEditorStore';
|
||||
import type { TilemapComponent } from '../../../TilemapComponent';
|
||||
|
||||
@@ -29,6 +29,10 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
|
||||
toggleLayerLocked,
|
||||
setLayerOpacity,
|
||||
renameLayer,
|
||||
showCollision,
|
||||
setShowCollision,
|
||||
editingCollision,
|
||||
setEditingCollision,
|
||||
} = useTilemapEditorStore();
|
||||
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
@@ -112,11 +116,54 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="layer-list">
|
||||
{/* Collision Layer - Special layer */}
|
||||
<div
|
||||
className={`layer-item collision-layer ${editingCollision ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
setEditingCollision(true);
|
||||
// Auto-show collision when editing
|
||||
if (!showCollision) {
|
||||
setShowCollision(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="layer-controls">
|
||||
<button
|
||||
className="icon-button small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowCollision(!showCollision);
|
||||
}}
|
||||
title={showCollision ? '隐藏碰撞层' : '显示碰撞层'}
|
||||
>
|
||||
{showCollision ? <Eye size={12} /> : <EyeOff size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="layer-info">
|
||||
{editingCollision && (
|
||||
<span className="layer-active-indicator" title="当前编辑碰撞">
|
||||
<Shield size={14} />
|
||||
</span>
|
||||
)}
|
||||
<span className="layer-name collision-name">
|
||||
<Shield size={12} style={{ marginRight: 4, opacity: 0.7 }} />
|
||||
碰撞层
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="layer-separator" />
|
||||
|
||||
{/* Tile Layers */}
|
||||
{layers.map((layer, index) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`layer-item ${index === currentLayer ? 'selected' : ''} ${layer.locked ? 'locked' : ''}`}
|
||||
onClick={() => setCurrentLayer(index)}
|
||||
className={`layer-item ${index === currentLayer && !editingCollision ? 'selected' : ''} ${layer.locked ? 'locked' : ''}`}
|
||||
onClick={() => {
|
||||
setEditingCollision(false);
|
||||
setCurrentLayer(index);
|
||||
}}
|
||||
>
|
||||
<div className="layer-controls">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Tile Set Selector Panel - Left panel for selecting tiles
|
||||
* 瓦片集选择面板 - 左侧面板用于选择瓦片
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Paintbrush, Eraser, PaintBucket, ChevronDown, Grid3x3, Search } from 'lucide-react';
|
||||
import { useTilemapEditorStore, type TilemapToolType } from '../../stores/TilemapEditorStore';
|
||||
import { TilesetPreview } from '../TilesetPreview';
|
||||
import '../../styles/TileSetSelectorPanel.css';
|
||||
|
||||
interface TilesetOption {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface TileSetSelectorPanelProps {
|
||||
tilesets: TilesetOption[];
|
||||
activeTilesetIndex: number;
|
||||
onTilesetChange: (index: number) => void;
|
||||
onAddTileset: () => void;
|
||||
}
|
||||
|
||||
export const TileSetSelectorPanel: React.FC<TileSetSelectorPanelProps> = ({
|
||||
tilesets,
|
||||
activeTilesetIndex,
|
||||
onTilesetChange,
|
||||
onAddTileset
|
||||
}) => {
|
||||
const {
|
||||
currentTool,
|
||||
setCurrentTool,
|
||||
tilesetImageUrl,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
tilesetColumns,
|
||||
tilesetRows,
|
||||
selectedTiles
|
||||
} = useTilemapEditorStore();
|
||||
|
||||
const [showTilesetDropdown, setShowTilesetDropdown] = useState(false);
|
||||
const [previewZoom, setPreviewZoom] = useState(1);
|
||||
|
||||
const handleToolChange = useCallback((tool: TilemapToolType) => {
|
||||
setCurrentTool(tool);
|
||||
}, [setCurrentTool]);
|
||||
|
||||
const activeTileset = tilesets[activeTilesetIndex];
|
||||
|
||||
return (
|
||||
<div className="tileset-selector-panel">
|
||||
{/* Tool buttons */}
|
||||
<div className="tileset-tools">
|
||||
<button
|
||||
className={`tileset-tool-btn ${currentTool === 'brush' ? 'active' : ''}`}
|
||||
onClick={() => handleToolChange('brush')}
|
||||
title="绘制"
|
||||
>
|
||||
<Paintbrush size={24} />
|
||||
<span>绘制</span>
|
||||
</button>
|
||||
<button
|
||||
className={`tileset-tool-btn ${currentTool === 'eraser' ? 'active' : ''}`}
|
||||
onClick={() => handleToolChange('eraser')}
|
||||
title="橡皮擦"
|
||||
>
|
||||
<Eraser size={24} />
|
||||
<span>橡皮擦</span>
|
||||
</button>
|
||||
<button
|
||||
className={`tileset-tool-btn ${currentTool === 'fill' ? 'active' : ''}`}
|
||||
onClick={() => handleToolChange('fill')}
|
||||
title="填充"
|
||||
>
|
||||
<PaintBucket size={24} />
|
||||
<span>填充</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active Tile Set selector */}
|
||||
<div className="tileset-selector">
|
||||
<div className="tileset-selector-row">
|
||||
<label>活跃瓦片集</label>
|
||||
<div className="tileset-selector-actions">
|
||||
<button className="tileset-action-btn" title="显示网格">
|
||||
<Grid3x3 size={14} />
|
||||
</button>
|
||||
<button className="tileset-action-btn" title="搜索">
|
||||
<Search size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tileset-dropdown-wrapper">
|
||||
<button
|
||||
className="tileset-dropdown-btn"
|
||||
onClick={() => setShowTilesetDropdown(!showTilesetDropdown)}
|
||||
>
|
||||
<span>{activeTileset?.name || '(无)'}</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{showTilesetDropdown && (
|
||||
<div className="tileset-dropdown-menu">
|
||||
<button
|
||||
className={`tileset-dropdown-item ${activeTilesetIndex === -1 ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
onTilesetChange(-1);
|
||||
setShowTilesetDropdown(false);
|
||||
}}
|
||||
>
|
||||
(无)
|
||||
</button>
|
||||
{tilesets.map((tileset, index) => (
|
||||
<button
|
||||
key={tileset.path}
|
||||
className={`tileset-dropdown-item ${index === activeTilesetIndex ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
onTilesetChange(index);
|
||||
setShowTilesetDropdown(false);
|
||||
}}
|
||||
>
|
||||
{tileset.name}
|
||||
</button>
|
||||
))}
|
||||
<div className="tileset-dropdown-divider" />
|
||||
<button
|
||||
className="tileset-dropdown-item add-new"
|
||||
onClick={() => {
|
||||
onAddTileset();
|
||||
setShowTilesetDropdown(false);
|
||||
}}
|
||||
>
|
||||
+ 添加瓦片集...
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zoom and title header */}
|
||||
<div className="tileset-header">
|
||||
<span className="tileset-zoom-label">缩放{previewZoom}:1</span>
|
||||
<span className="tileset-title">瓦片集选择器</span>
|
||||
</div>
|
||||
|
||||
{/* Tile preview area */}
|
||||
<div className="tileset-preview-area">
|
||||
{tilesetImageUrl ? (
|
||||
<TilesetPreview
|
||||
imageUrl={tilesetImageUrl}
|
||||
tileWidth={tileWidth}
|
||||
tileHeight={tileHeight}
|
||||
columns={tilesetColumns}
|
||||
rows={tilesetRows}
|
||||
/>
|
||||
) : (
|
||||
<div className="tileset-empty-hint">
|
||||
<button className="tileset-select-btn" onClick={onAddTileset}>
|
||||
选择瓦片集
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection info */}
|
||||
{selectedTiles && (
|
||||
<div className="tileset-selection-info">
|
||||
已选择: {selectedTiles.width}×{selectedTiles.height}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TileSetSelectorPanel;
|
||||
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* Tilemap Details Panel - Right panel with grouped properties
|
||||
* Tilemap 详情面板 - 右侧分组属性面板
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Copy,
|
||||
X,
|
||||
Search,
|
||||
Settings,
|
||||
Eye,
|
||||
EyeOff
|
||||
} from 'lucide-react';
|
||||
import { useTilemapEditorStore, type LayerState } from '../../stores/TilemapEditorStore';
|
||||
import type { TilemapComponent } from '../../../TilemapComponent';
|
||||
import '../../styles/TilemapDetailsPanel.css';
|
||||
|
||||
interface TilemapDetailsPanelProps {
|
||||
tilemap: TilemapComponent | null;
|
||||
onAddLayer: () => void;
|
||||
onRemoveLayer: (index: number) => void;
|
||||
onMoveLayer: (from: number, to: number) => void;
|
||||
onTilemapChange: () => void;
|
||||
onOpenAssetPicker: () => void;
|
||||
}
|
||||
|
||||
// Collapsible section component
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Section: React.FC<SectionProps> = ({ title, defaultOpen = true, children }) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="details-section">
|
||||
<div
|
||||
className="details-section-header"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="details-section-content">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Property row component
|
||||
interface PropertyRowProps {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
indent?: boolean;
|
||||
}
|
||||
|
||||
const PropertyRow: React.FC<PropertyRowProps> = ({ label, children, indent }) => (
|
||||
<div className={`property-row ${indent ? 'indented' : ''}`}>
|
||||
<label>{label}</label>
|
||||
<div className="property-value">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Toggle property - unified style matching PropertyInspector
|
||||
interface TogglePropertyProps {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
indent?: boolean;
|
||||
}
|
||||
|
||||
const ToggleProperty: React.FC<TogglePropertyProps> = ({ label, checked, onChange, indent }) => (
|
||||
<div className={`property-row toggle-row ${indent ? 'indented' : ''}`}>
|
||||
<label>{label}</label>
|
||||
<button
|
||||
className={`property-toggle ${checked ? 'property-toggle-on' : 'property-toggle-off'}`}
|
||||
onClick={() => onChange(!checked)}
|
||||
type="button"
|
||||
>
|
||||
<span className="property-toggle-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Number input property
|
||||
interface NumberPropertyProps {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
const NumberProperty: React.FC<NumberPropertyProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
step = 1
|
||||
}) => (
|
||||
<PropertyRow label={label}>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
// Color property - unified style matching PropertyInspector
|
||||
interface ColorPropertyProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
showReset?: boolean;
|
||||
}
|
||||
|
||||
const ColorProperty: React.FC<ColorPropertyProps> = ({ label, value, onChange }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handlePreviewClick = () => {
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<PropertyRow label={label}>
|
||||
<div className="property-color-wrapper">
|
||||
<div
|
||||
className="property-color-preview"
|
||||
style={{ backgroundColor: value }}
|
||||
onClick={handlePreviewClick}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="color"
|
||||
className="property-input-color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="property-input property-input-color-text"
|
||||
value={value.toUpperCase()}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (/^#[0-9A-Fa-f]{0,6}$/.test(val)) {
|
||||
onChange(val);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value;
|
||||
if (!/^#[0-9A-Fa-f]{6}$/.test(val)) {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PropertyRow>
|
||||
);
|
||||
};
|
||||
|
||||
export const TilemapDetailsPanel: React.FC<TilemapDetailsPanelProps> = ({
|
||||
tilemap,
|
||||
onAddLayer,
|
||||
onRemoveLayer,
|
||||
onMoveLayer,
|
||||
onTilemapChange,
|
||||
onOpenAssetPicker
|
||||
}) => {
|
||||
const {
|
||||
layers,
|
||||
currentLayer,
|
||||
setCurrentLayer,
|
||||
toggleLayerVisibility,
|
||||
setLayerOpacity,
|
||||
showCollision,
|
||||
setShowCollision
|
||||
} = useTilemapEditorStore();
|
||||
|
||||
// Layer properties state - synced with store's visibility
|
||||
const selectedLayer = layers[currentLayer];
|
||||
const [hiddenInGame, setHiddenInGame] = useState(false);
|
||||
const [layerCollides, setLayerCollides] = useState(true);
|
||||
const [overrideCollisionThickness, setOverrideCollisionThickness] = useState(false);
|
||||
const [overrideCollisionOffset, setOverrideCollisionOffset] = useState(false);
|
||||
const [collisionThickness, setCollisionThickness] = useState(50.0);
|
||||
const [collisionOffset, setCollisionOffset] = useState(0.0);
|
||||
const [layerColor, setLayerColor] = useState('#ffffff');
|
||||
|
||||
// hiddenInEditor is derived from layer visibility (inverse relationship)
|
||||
const hiddenInEditor = selectedLayer ? !selectedLayer.visible : false;
|
||||
|
||||
const handleHiddenInEditorChange = useCallback((hidden: boolean) => {
|
||||
if (currentLayer >= 0 && currentLayer < layers.length) {
|
||||
toggleLayerVisibility(currentLayer);
|
||||
// Also update tilemap component
|
||||
if (tilemap && tilemap.layers[currentLayer]) {
|
||||
tilemap.layers[currentLayer].visible = !hidden;
|
||||
tilemap.renderDirty = true;
|
||||
onTilemapChange();
|
||||
}
|
||||
}
|
||||
}, [currentLayer, layers.length, toggleLayerVisibility, tilemap, onTilemapChange]);
|
||||
|
||||
// Handle eye icon click in layer list
|
||||
const handleLayerVisibilityToggle = useCallback((index: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toggleLayerVisibility(index);
|
||||
// Also update tilemap component
|
||||
if (tilemap && tilemap.layers[index]) {
|
||||
tilemap.layers[index].visible = !tilemap.layers[index].visible;
|
||||
tilemap.renderDirty = true;
|
||||
onTilemapChange();
|
||||
}
|
||||
}, [toggleLayerVisibility, tilemap, onTilemapChange]);
|
||||
|
||||
// Colors
|
||||
const [tileGridColor, setTileGridColor] = useState('#333333');
|
||||
const [multiTileGridColor, setMultiTileGridColor] = useState('#ff0000');
|
||||
const [layerGridColor, setLayerGridColor] = useState('#00ff00');
|
||||
|
||||
const handleLayerSelect = useCallback((index: number) => {
|
||||
setCurrentLayer(index);
|
||||
}, [setCurrentLayer]);
|
||||
|
||||
const handleMapWidthChange = useCallback((value: number) => {
|
||||
if (tilemap && value > 0) {
|
||||
tilemap.resize(value, tilemap.height, 'bottom-left');
|
||||
onTilemapChange();
|
||||
}
|
||||
}, [tilemap, onTilemapChange]);
|
||||
|
||||
const handleMapHeightChange = useCallback((value: number) => {
|
||||
if (tilemap && value > 0) {
|
||||
tilemap.resize(tilemap.width, value, 'bottom-left');
|
||||
onTilemapChange();
|
||||
}
|
||||
}, [tilemap, onTilemapChange]);
|
||||
|
||||
const handleTileWidthChange = useCallback((value: number) => {
|
||||
if (tilemap && value > 0) {
|
||||
tilemap.tileWidth = value;
|
||||
onTilemapChange();
|
||||
}
|
||||
}, [tilemap, onTilemapChange]);
|
||||
|
||||
const handleTileHeightChange = useCallback((value: number) => {
|
||||
if (tilemap && value > 0) {
|
||||
tilemap.tileHeight = value;
|
||||
onTilemapChange();
|
||||
}
|
||||
}, [tilemap, onTilemapChange]);
|
||||
|
||||
if (!tilemap) {
|
||||
return (
|
||||
<div className="tilemap-details-panel">
|
||||
<div className="details-empty">
|
||||
未选择瓦片地图
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tilemap-details-panel">
|
||||
<div className="details-header">
|
||||
<span className="details-header-title">细节</span>
|
||||
<div className="details-header-actions">
|
||||
<div className="details-search-inline">
|
||||
<Search size={12} />
|
||||
<input type="text" placeholder="搜索" />
|
||||
</div>
|
||||
<button className="details-settings-btn" title="设置">
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="details-content">
|
||||
{/* Tile Map Section */}
|
||||
<Section title="瓦片地图">
|
||||
<div className="property-row">
|
||||
<label>瓦片层列表</label>
|
||||
<span className="layer-count-badge">图层 {currentLayer + 1}</span>
|
||||
</div>
|
||||
|
||||
{/* Tile Layers List */}
|
||||
<div className="layer-list-container">
|
||||
{layers.map((layer, index) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`layer-list-item ${index === currentLayer ? 'selected' : ''}`}
|
||||
onClick={() => handleLayerSelect(index)}
|
||||
>
|
||||
<button
|
||||
className={`layer-visibility-btn ${layer.visible ? '' : 'hidden'}`}
|
||||
onClick={(e) => handleLayerVisibilityToggle(index, e)}
|
||||
title={layer.visible ? '隐藏图层' : '显示图层'}
|
||||
>
|
||||
{layer.visible ? <Eye size={12} /> : <EyeOff size={12} />}
|
||||
</button>
|
||||
<span className="layer-icon">◆</span>
|
||||
<span>{layer.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Layer action buttons */}
|
||||
<div className="layer-actions-row">
|
||||
<button
|
||||
className="layer-action-btn add"
|
||||
onClick={onAddLayer}
|
||||
title="添加图层"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="layer-action-btn"
|
||||
onClick={() => currentLayer > 0 && onMoveLayer(currentLayer, currentLayer - 1)}
|
||||
disabled={currentLayer <= 0}
|
||||
title="上移"
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="layer-action-btn"
|
||||
onClick={() => currentLayer < layers.length - 1 && onMoveLayer(currentLayer, currentLayer + 1)}
|
||||
disabled={currentLayer >= layers.length - 1}
|
||||
title="下移"
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="layer-action-btn"
|
||||
title="复制"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="layer-action-btn danger"
|
||||
onClick={() => layers.length > 1 && onRemoveLayer(currentLayer)}
|
||||
disabled={layers.length <= 1}
|
||||
title="删除图层"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Selected Layer Section */}
|
||||
<Section title="选定层">
|
||||
<PropertyRow label="">
|
||||
<span className="selected-layer-name">{selectedLayer?.name || '图层 1'}</span>
|
||||
</PropertyRow>
|
||||
<ToggleProperty
|
||||
label="编辑器中隐藏"
|
||||
checked={hiddenInEditor}
|
||||
onChange={handleHiddenInEditorChange}
|
||||
/>
|
||||
<ToggleProperty
|
||||
label="游戏中隐藏"
|
||||
checked={hiddenInGame}
|
||||
onChange={setHiddenInGame}
|
||||
/>
|
||||
<ToggleProperty
|
||||
label="图层碰撞"
|
||||
checked={layerCollides}
|
||||
onChange={setLayerCollides}
|
||||
/>
|
||||
<ToggleProperty
|
||||
label="重载碰撞厚度"
|
||||
checked={overrideCollisionThickness}
|
||||
onChange={setOverrideCollisionThickness}
|
||||
indent
|
||||
/>
|
||||
<ToggleProperty
|
||||
label="重载碰撞偏移"
|
||||
checked={overrideCollisionOffset}
|
||||
onChange={setOverrideCollisionOffset}
|
||||
indent
|
||||
/>
|
||||
{overrideCollisionThickness && (
|
||||
<NumberProperty
|
||||
label="碰撞厚度重载"
|
||||
value={collisionThickness}
|
||||
onChange={setCollisionThickness}
|
||||
/>
|
||||
)}
|
||||
{overrideCollisionOffset && (
|
||||
<NumberProperty
|
||||
label="碰撞偏移重载"
|
||||
value={collisionOffset}
|
||||
onChange={setCollisionOffset}
|
||||
/>
|
||||
)}
|
||||
<ColorProperty
|
||||
label="图层颜色"
|
||||
value={layerColor}
|
||||
onChange={setLayerColor}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Setup Section */}
|
||||
<Section title="配置">
|
||||
<NumberProperty
|
||||
label="地图宽度"
|
||||
value={tilemap.width}
|
||||
onChange={handleMapWidthChange}
|
||||
min={1}
|
||||
/>
|
||||
<NumberProperty
|
||||
label="地图高度"
|
||||
value={tilemap.height}
|
||||
onChange={handleMapHeightChange}
|
||||
min={1}
|
||||
/>
|
||||
<NumberProperty
|
||||
label="瓦片宽度"
|
||||
value={tilemap.tileWidth}
|
||||
onChange={handleTileWidthChange}
|
||||
min={1}
|
||||
/>
|
||||
<NumberProperty
|
||||
label="瓦片高度"
|
||||
value={tilemap.tileHeight}
|
||||
onChange={handleTileHeightChange}
|
||||
min={1}
|
||||
/>
|
||||
<NumberProperty
|
||||
label="逻辑单位像素"
|
||||
value={1.0}
|
||||
onChange={() => {}}
|
||||
step={0.1}
|
||||
/>
|
||||
<NumberProperty
|
||||
label="逐图层分隔"
|
||||
value={4.0}
|
||||
onChange={() => {}}
|
||||
step={0.1}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Material Section */}
|
||||
<Section title="材质" defaultOpen={false}>
|
||||
<PropertyRow label="材质">
|
||||
<button className="asset-dropdown">
|
||||
<span>Masked</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
</PropertyRow>
|
||||
</Section>
|
||||
|
||||
{/* Advanced Section */}
|
||||
<Section title="高级" defaultOpen={false}>
|
||||
<PropertyRow label="投射模式">
|
||||
<select defaultValue="orthogonal">
|
||||
<option value="orthogonal">正交</option>
|
||||
<option value="isometric">等轴测</option>
|
||||
<option value="hexagonal">六方</option>
|
||||
</select>
|
||||
</PropertyRow>
|
||||
<NumberProperty
|
||||
label="六方格边长度"
|
||||
value={0}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<ColorProperty
|
||||
label="背景颜色"
|
||||
value={tileGridColor}
|
||||
onChange={setTileGridColor}
|
||||
showReset
|
||||
/>
|
||||
<ColorProperty
|
||||
label="瓦片网格颜色"
|
||||
value={tileGridColor}
|
||||
onChange={setTileGridColor}
|
||||
showReset
|
||||
/>
|
||||
<ColorProperty
|
||||
label="多瓦片网格颜色"
|
||||
value={multiTileGridColor}
|
||||
onChange={setMultiTileGridColor}
|
||||
showReset
|
||||
/>
|
||||
<NumberProperty
|
||||
label="多瓦片网格宽度"
|
||||
value={0}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Collision Section */}
|
||||
<Section title="碰撞" defaultOpen={false}>
|
||||
<ToggleProperty
|
||||
label="显示碰撞"
|
||||
checked={showCollision}
|
||||
onChange={setShowCollision}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TilemapDetailsPanel;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,21 @@ import type { IGizmoRenderData, IRectGizmoData, IGridGizmoData, GizmoColor } fro
|
||||
import { GizmoColors, GizmoRegistry } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/ecs-components';
|
||||
import { TilemapComponent } from '../../TilemapComponent';
|
||||
import { TilemapCollider2DComponent, TilemapColliderMode } from '../../physics/TilemapCollider2DComponent';
|
||||
|
||||
/**
|
||||
* Tilemap Collider Gizmo 颜色配置
|
||||
*/
|
||||
const TilemapColliderGizmoColors = {
|
||||
/** 碰撞体边框 - 青色 */
|
||||
collider: { r: 0.0, g: 0.8, b: 0.8, a: 0.8 } as GizmoColor,
|
||||
/** 碰撞体填充 - 半透明青色 */
|
||||
colliderFill: { r: 0.0, g: 0.8, b: 0.8, a: 0.2 } as GizmoColor,
|
||||
/** 选中时的碰撞体 - 亮青色 */
|
||||
colliderSelected: { r: 0.0, g: 1.0, b: 1.0, a: 1.0 } as GizmoColor,
|
||||
/** 触发器 - 橙色 */
|
||||
trigger: { r: 1.0, g: 0.6, b: 0.0, a: 0.8 } as GizmoColor,
|
||||
};
|
||||
|
||||
/**
|
||||
* Gizmo provider function for TilemapComponent.
|
||||
@@ -98,6 +113,222 @@ function tilemapGizmoProvider(
|
||||
return gizmos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gizmo provider function for TilemapCollider2DComponent.
|
||||
* TilemapCollider2DComponent 的 gizmo 提供者函数。
|
||||
*
|
||||
* Provides gizmo data for collision visualization:
|
||||
* - Shows collision rectangles (per-tile or merged)
|
||||
* - Different colors for trigger vs collider
|
||||
*
|
||||
* 提供碰撞可视化的 gizmo 数据:
|
||||
* - 显示碰撞矩形(每格或合并)
|
||||
* - 触发器和碰撞体使用不同颜色
|
||||
*/
|
||||
function tilemapCollider2DGizmoProvider(
|
||||
collider: TilemapCollider2DComponent,
|
||||
entity: Entity,
|
||||
isSelected: boolean
|
||||
): IGizmoRenderData[] {
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
const tilemap = entity.getComponent(TilemapComponent);
|
||||
|
||||
if (!transform || !tilemap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const gizmos: IGizmoRenderData[] = [];
|
||||
|
||||
// 获取碰撞颜色
|
||||
const color = isSelected
|
||||
? TilemapColliderGizmoColors.colliderSelected
|
||||
: (collider.isTrigger ? TilemapColliderGizmoColors.trigger : TilemapColliderGizmoColors.collider);
|
||||
|
||||
// 获取实体位置偏移
|
||||
const offsetX = transform.position.x;
|
||||
const offsetY = transform.position.y;
|
||||
const scaleX = transform.scale.x;
|
||||
const scaleY = transform.scale.y;
|
||||
|
||||
// 获取旋转
|
||||
const rotation = typeof transform.rotation === 'number'
|
||||
? transform.rotation
|
||||
: transform.rotation.z;
|
||||
|
||||
// 计算地图总高度(像素),用于 Y 轴翻转
|
||||
// Calculate total map height (pixels) for Y-axis flip
|
||||
const mapPixelHeight = tilemap.height * tilemap.tileHeight;
|
||||
|
||||
// 如果已有碰撞矩形缓存,直接使用
|
||||
if (collider._collisionRects.length > 0) {
|
||||
// 使用已生成的碰撞矩形
|
||||
for (const rect of collider._collisionRects) {
|
||||
// Y 轴翻转:rect.y 是从顶部计算的,需要翻转到底部
|
||||
// Y-axis flip: rect.y is calculated from top, needs flip to bottom
|
||||
const flippedY = mapPixelHeight - rect.y - rect.height;
|
||||
const worldX = offsetX + (rect.x + rect.width / 2) * scaleX;
|
||||
const worldY = offsetY + (flippedY + rect.height / 2) * scaleY;
|
||||
const worldWidth = rect.width * scaleX;
|
||||
const worldHeight = rect.height * scaleY;
|
||||
|
||||
const rectGizmo: IRectGizmoData = {
|
||||
type: 'rect',
|
||||
x: worldX,
|
||||
y: worldY,
|
||||
width: worldWidth,
|
||||
height: worldHeight,
|
||||
rotation,
|
||||
originX: 0.5,
|
||||
originY: 0.5,
|
||||
color,
|
||||
showHandles: false
|
||||
};
|
||||
gizmos.push(rectGizmo);
|
||||
}
|
||||
} else {
|
||||
// 如果没有缓存,根据模式生成预览
|
||||
const collisionData = tilemap.collisionData;
|
||||
const width = tilemap.width;
|
||||
const height = tilemap.height;
|
||||
const tileWidth = tilemap.tileWidth;
|
||||
const tileHeight = tilemap.tileHeight;
|
||||
|
||||
if (collider.colliderMode === TilemapColliderMode.PerTile) {
|
||||
// PerTile 模式:每个碰撞格子单独显示
|
||||
for (let row = 0; row < height; row++) {
|
||||
for (let col = 0; col < width; col++) {
|
||||
if (collisionData[row * width + col] > 0) {
|
||||
// Y 轴翻转:数据存储 row 0 在顶部,渲染时 Y-up 需要翻转
|
||||
// Y-axis flip: data stores row 0 at top, rendering needs Y-up flip
|
||||
const flippedRow = height - 1 - row;
|
||||
const worldX = offsetX + (col * tileWidth + tileWidth / 2) * scaleX;
|
||||
const worldY = offsetY + (flippedRow * tileHeight + tileHeight / 2) * scaleY;
|
||||
const worldWidth = tileWidth * scaleX;
|
||||
const worldHeight = tileHeight * scaleY;
|
||||
|
||||
const rectGizmo: IRectGizmoData = {
|
||||
type: 'rect',
|
||||
x: worldX,
|
||||
y: worldY,
|
||||
width: worldWidth,
|
||||
height: worldHeight,
|
||||
rotation,
|
||||
originX: 0.5,
|
||||
originY: 0.5,
|
||||
color,
|
||||
showHandles: false
|
||||
};
|
||||
gizmos.push(rectGizmo);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Merged 模式:使用贪心算法合并相邻格子
|
||||
const rects = generateMergedRects(collisionData, width, height, tileWidth, tileHeight);
|
||||
|
||||
for (const rect of rects) {
|
||||
// Y 轴翻转:rect.y 是从顶部计算的,需要翻转到底部
|
||||
// Y-axis flip: rect.y is calculated from top, needs flip to bottom
|
||||
const flippedY = mapPixelHeight - rect.y - rect.height;
|
||||
const worldX = offsetX + (rect.x + rect.width / 2) * scaleX;
|
||||
const worldY = offsetY + (flippedY + rect.height / 2) * scaleY;
|
||||
const worldWidth = rect.width * scaleX;
|
||||
const worldHeight = rect.height * scaleY;
|
||||
|
||||
const rectGizmo: IRectGizmoData = {
|
||||
type: 'rect',
|
||||
x: worldX,
|
||||
y: worldY,
|
||||
width: worldWidth,
|
||||
height: worldHeight,
|
||||
rotation,
|
||||
originX: 0.5,
|
||||
originY: 0.5,
|
||||
color,
|
||||
showHandles: false
|
||||
};
|
||||
gizmos.push(rectGizmo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gizmos;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成合并的碰撞矩形(贪心算法)
|
||||
* 用于 Gizmo 预览,与 TilemapCollider2DComponent 中的算法相同
|
||||
*/
|
||||
function generateMergedRects(
|
||||
collisionData: Uint32Array,
|
||||
width: number,
|
||||
height: number,
|
||||
tileWidth: number,
|
||||
tileHeight: number
|
||||
): Array<{ x: number; y: number; width: number; height: number }> {
|
||||
if (collisionData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const processed = new Array(width * height).fill(false);
|
||||
const rects: Array<{ x: number; y: number; width: number; height: number }> = [];
|
||||
|
||||
for (let row = 0; row < height; row++) {
|
||||
for (let col = 0; col < width; col++) {
|
||||
const index = row * width + col;
|
||||
|
||||
if (processed[index] || collisionData[index] === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 找到水平方向最大范围
|
||||
let endCol = col;
|
||||
while (
|
||||
endCol < width &&
|
||||
collisionData[row * width + endCol] > 0 &&
|
||||
!processed[row * width + endCol]
|
||||
) {
|
||||
endCol++;
|
||||
}
|
||||
const rectWidth = endCol - col;
|
||||
|
||||
// 找到垂直方向最大范围
|
||||
let endRow = row;
|
||||
let canExtend = true;
|
||||
while (canExtend && endRow < height) {
|
||||
for (let c = col; c < endCol; c++) {
|
||||
const idx = endRow * width + c;
|
||||
if (collisionData[idx] === 0 || processed[idx]) {
|
||||
canExtend = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (canExtend) {
|
||||
endRow++;
|
||||
}
|
||||
}
|
||||
const rectHeight = endRow - row;
|
||||
|
||||
// 标记所有包含的格子为已处理
|
||||
for (let r = row; r < endRow; r++) {
|
||||
for (let c = col; c < endCol; c++) {
|
||||
processed[r * width + c] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加合并后的矩形
|
||||
rects.push({
|
||||
x: col * tileWidth,
|
||||
y: row * tileHeight,
|
||||
width: rectWidth * tileWidth,
|
||||
height: rectHeight * tileHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register gizmo provider for TilemapComponent.
|
||||
* 为 TilemapComponent 注册 gizmo 提供者。
|
||||
@@ -108,6 +339,7 @@ function tilemapGizmoProvider(
|
||||
*/
|
||||
export function registerTilemapGizmo(): void {
|
||||
GizmoRegistry.register(TilemapComponent, tilemapGizmoProvider);
|
||||
GizmoRegistry.register(TilemapCollider2DComponent, tilemapCollider2DGizmoProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,4 +348,5 @@ export function registerTilemapGizmo(): void {
|
||||
*/
|
||||
export function unregisterTilemapGizmo(): void {
|
||||
GizmoRegistry.unregister(TilemapComponent);
|
||||
GizmoRegistry.unregister(TilemapCollider2DComponent);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ import type {
|
||||
EntityCreationTemplate,
|
||||
ComponentAction,
|
||||
ComponentInspectorProviderDef,
|
||||
GizmoProviderRegistration
|
||||
GizmoProviderRegistration,
|
||||
FileActionHandler,
|
||||
FileCreationTemplate
|
||||
} from '@esengine/editor-core';
|
||||
import {
|
||||
PanelPosition,
|
||||
@@ -21,18 +23,25 @@ import {
|
||||
MessageHub,
|
||||
ComponentRegistry,
|
||||
IDialogService,
|
||||
IFileSystemService
|
||||
IFileSystemService,
|
||||
UIRegistry,
|
||||
FileActionRegistry
|
||||
} from '@esengine/editor-core';
|
||||
import type { IDialog, IFileSystem } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/ecs-components';
|
||||
|
||||
// Local imports
|
||||
import { TilemapComponent } from '../TilemapComponent';
|
||||
import { TilemapCollider2DComponent } from '../physics/TilemapCollider2DComponent';
|
||||
import { TilemapEditorPanel } from './components/panels/TilemapEditorPanel';
|
||||
import { TilemapInspectorProvider } from './providers/TilemapInspectorProvider';
|
||||
import { registerTilemapGizmo } from './gizmos/TilemapGizmo';
|
||||
import { useTilemapEditorStore } from './stores/TilemapEditorStore';
|
||||
|
||||
// 导入编辑器 CSS 样式(会被 vite 自动处理并注入到 DOM)
|
||||
// Import editor CSS styles (automatically handled and injected by vite)
|
||||
import './styles/TilemapEditor.css';
|
||||
|
||||
// Re-exports
|
||||
export { TilemapEditorPanel } from './components/panels/TilemapEditorPanel';
|
||||
export { TilesetPanel } from './components/panels/TilesetPanel';
|
||||
@@ -70,6 +79,14 @@ export class TilemapEditorModule implements IEditorModuleLoader {
|
||||
description: 'Tilemap component for tile-based levels',
|
||||
icon: 'Grid3X3'
|
||||
});
|
||||
|
||||
componentRegistry.register({
|
||||
name: 'TilemapCollider2D',
|
||||
type: TilemapCollider2DComponent,
|
||||
category: 'components.category.physics',
|
||||
description: 'Generates physics colliders from tilemap collision data',
|
||||
icon: 'Shield'
|
||||
});
|
||||
}
|
||||
|
||||
// 订阅 tilemap:create-asset 消息 | Subscribe to tilemap:create-asset message
|
||||
@@ -84,11 +101,25 @@ export class TilemapEditorModule implements IEditorModuleLoader {
|
||||
this.unsubscribers.push(unsubscribe);
|
||||
}
|
||||
|
||||
// 注册资产创建消息映射 | Register asset creation message mappings
|
||||
const fileActionRegistry = services.resolve(FileActionRegistry);
|
||||
if (fileActionRegistry) {
|
||||
fileActionRegistry.registerAssetCreationMapping({
|
||||
extension: '.tilemap',
|
||||
createMessage: 'tilemap:create-asset'
|
||||
});
|
||||
fileActionRegistry.registerAssetCreationMapping({
|
||||
extension: '.tileset',
|
||||
createMessage: 'tileset:create-asset'
|
||||
});
|
||||
}
|
||||
|
||||
// 注册 Tilemap Gizmo | Register Tilemap gizmo
|
||||
registerTilemapGizmo();
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
// 清理订阅 | Clean up subscriptions
|
||||
this.unsubscribers.forEach(unsub => unsub());
|
||||
this.unsubscribers = [];
|
||||
}
|
||||
@@ -168,20 +199,102 @@ export class TilemapEditorModule implements IEditorModuleLoader {
|
||||
}
|
||||
|
||||
getComponentActions(): ComponentAction[] {
|
||||
// 移除编辑按钮,改为双击 tilemap 文件打开编辑器
|
||||
return [];
|
||||
}
|
||||
|
||||
getFileActionHandlers(): FileActionHandler[] {
|
||||
return [
|
||||
{
|
||||
id: 'tilemap-edit',
|
||||
componentName: 'Tilemap',
|
||||
label: '编辑 Tilemap',
|
||||
icon: 'Edit3',
|
||||
execute: (_component: unknown, entity: Entity) => {
|
||||
extensions: ['tilemap', 'json'],
|
||||
onDoubleClick: (filePath: string) => {
|
||||
// 只处理 .tilemap 和 .tilemap.json 文件
|
||||
const lowerPath = filePath.toLowerCase();
|
||||
if (!lowerPath.endsWith('.tilemap') && !lowerPath.endsWith('.tilemap.json')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先设置待打开的文件路径到 store
|
||||
useTilemapEditorStore.getState().setPendingFilePath(filePath);
|
||||
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
const entityIdStr = String(entity.id);
|
||||
useTilemapEditorStore.getState().setEntityId(entityIdStr);
|
||||
messageHub.publish('dynamic-panel:open', { panelId: 'tilemap-editor', title: 'Tilemap Editor' });
|
||||
// 打开 tilemap 编辑器面板(面板挂载后会从 store 读取 pendingFilePath)
|
||||
messageHub.publish('dynamic-panel:open', {
|
||||
panelId: 'tilemap-editor',
|
||||
title: `Tilemap Editor - ${filePath.split(/[\\/]/).pop()}`
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
extensions: ['tileset', 'json'],
|
||||
onDoubleClick: (filePath: string) => {
|
||||
// 只处理 .tileset 和 .tileset.json 文件
|
||||
const lowerPath = filePath.toLowerCase();
|
||||
if (!lowerPath.endsWith('.tileset') && !lowerPath.endsWith('.tileset.json')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
// 发送消息打开 tileset 预览
|
||||
messageHub.publish('tileset:open-file', { filePath });
|
||||
messageHub.publish('dynamic-panel:open', {
|
||||
panelId: 'tilemap-editor',
|
||||
title: `Tileset - ${filePath.split(/[\\/]/).pop()}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getFileCreationTemplates(): FileCreationTemplate[] {
|
||||
return [
|
||||
{
|
||||
id: 'create-tilemap',
|
||||
label: 'Tilemap',
|
||||
extension: 'tilemap',
|
||||
icon: 'Grid3X3',
|
||||
category: 'tilemap',
|
||||
getContent: (_fileName: string) => {
|
||||
const defaultTilemapData = {
|
||||
width: 20,
|
||||
height: 15,
|
||||
tileWidth: 16,
|
||||
tileHeight: 16,
|
||||
layers: [
|
||||
{
|
||||
name: 'Layer 1',
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
data: new Array(20 * 15).fill(0)
|
||||
}
|
||||
],
|
||||
tilesets: []
|
||||
};
|
||||
return JSON.stringify(defaultTilemapData, null, 2);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'create-tileset',
|
||||
label: 'Tileset',
|
||||
extension: 'tileset',
|
||||
icon: 'LayoutGrid',
|
||||
category: 'tilemap',
|
||||
getContent: (_fileName: string) => {
|
||||
const defaultTilesetData = {
|
||||
name: 'New Tileset',
|
||||
tileWidth: 16,
|
||||
tileHeight: 16,
|
||||
spacing: 0,
|
||||
margin: 0,
|
||||
imageSource: '',
|
||||
tiles: []
|
||||
};
|
||||
return JSON.stringify(defaultTilesetData, null, 2);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -201,8 +314,8 @@ export class TilemapEditorModule implements IEditorModuleLoader {
|
||||
|
||||
const filePath = await dialog.saveDialog({
|
||||
title: '创建 Tilemap 资产',
|
||||
filters: [{ name: 'Tilemap', extensions: ['tilemap.json'] }],
|
||||
defaultPath: 'new-tilemap.tilemap.json'
|
||||
filters: [{ name: 'Tilemap', extensions: ['tilemap'] }],
|
||||
defaultPath: 'new-tilemap.tilemap'
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
@@ -242,5 +355,5 @@ export class TilemapEditorModule implements IEditorModuleLoader {
|
||||
export const tilemapEditorModule = new TilemapEditorModule();
|
||||
|
||||
// Plugin exports
|
||||
export { TilemapPlugin, TilemapRuntimeModule } from './TilemapPlugin';
|
||||
export { TilemapPlugin } from './TilemapPlugin';
|
||||
export default tilemapEditorModule;
|
||||
|
||||
@@ -26,6 +26,11 @@ export interface TilemapEditorState {
|
||||
// Current editing target
|
||||
entityId: string | null;
|
||||
|
||||
// Pending file to open (for file-based editing)
|
||||
pendingFilePath: string | null;
|
||||
// Current file being edited (for file-based editing)
|
||||
currentFilePath: string | null;
|
||||
|
||||
// Tileset
|
||||
tilesetImageUrl: string | null;
|
||||
tilesetColumns: number;
|
||||
@@ -58,6 +63,9 @@ export interface TilemapEditorState {
|
||||
|
||||
// Actions
|
||||
setEntityId: (id: string | null) => void;
|
||||
setPendingFilePath: (path: string | null) => void;
|
||||
setCurrentFilePath: (path: string | null) => void;
|
||||
clearPendingFile: () => void;
|
||||
setTileset: (url: string | null, columns: number, rows: number, tileWidth: number, tileHeight: number) => void;
|
||||
setSelectedTiles: (selection: TileSelection | null) => void;
|
||||
setCurrentTool: (tool: TilemapToolType) => void;
|
||||
@@ -83,6 +91,8 @@ export interface TilemapEditorState {
|
||||
|
||||
const initialState = {
|
||||
entityId: null,
|
||||
pendingFilePath: null,
|
||||
currentFilePath: null,
|
||||
tilesetImageUrl: null,
|
||||
tilesetColumns: 0,
|
||||
tilesetRows: 0,
|
||||
@@ -108,6 +118,12 @@ export const useTilemapEditorStore = create<TilemapEditorState>((set, get) => ({
|
||||
|
||||
setEntityId: (id) => set({ entityId: id }),
|
||||
|
||||
setPendingFilePath: (path) => set({ pendingFilePath: path }),
|
||||
|
||||
setCurrentFilePath: (path) => set({ currentFilePath: path }),
|
||||
|
||||
clearPendingFile: () => set({ pendingFilePath: null }),
|
||||
|
||||
setTileset: (url, columns, rows, tileWidth, tileHeight) => set({
|
||||
tilesetImageUrl: url,
|
||||
tilesetColumns: columns,
|
||||
|
||||
293
packages/tilemap/src/editor/styles/TileSetSelectorPanel.css
Normal file
293
packages/tilemap/src/editor/styles/TileSetSelectorPanel.css
Normal file
@@ -0,0 +1,293 @@
|
||||
/* ==================== Tile Set Selector Panel Styles ==================== */
|
||||
|
||||
.tileset-selector-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #252526;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ==================== Tool Buttons Row ==================== */
|
||||
.tileset-tools {
|
||||
display: flex;
|
||||
padding: 8px;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
background: #2d2d30;
|
||||
}
|
||||
|
||||
.tileset-tool-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
background: #252526;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 3px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tileset-tool-btn span {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tileset-tool-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.tileset-tool-btn:hover {
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
border-color: #4c4c4c;
|
||||
}
|
||||
|
||||
.tileset-tool-btn.active {
|
||||
background: linear-gradient(180deg, #ffc107 0%, #e6a800 100%);
|
||||
border-color: #cc9600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.tileset-tool-btn.active:hover {
|
||||
background: linear-gradient(180deg, #ffd54f 0%, #ffca28 100%);
|
||||
}
|
||||
|
||||
.tileset-tool-btn.active svg {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* ==================== Tileset Selector Section ==================== */
|
||||
.tileset-selector {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.tileset-selector-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tileset-selector label {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.tileset-selector-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tileset-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.tileset-action-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.tileset-action-btn:hover {
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* ==================== Tileset Dropdown ==================== */
|
||||
.tileset-dropdown-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tileset-dropdown-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 3px;
|
||||
color: #c0c0c0;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.1s ease;
|
||||
}
|
||||
|
||||
.tileset-dropdown-btn:hover {
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.tileset-dropdown-btn span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tileset-dropdown-btn svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tileset-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 2px;
|
||||
background: #252526;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tileset-dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #c0c0c0;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.tileset-dropdown-item:hover {
|
||||
background: #3c3c3c;
|
||||
}
|
||||
|
||||
.tileset-dropdown-item.selected {
|
||||
background: #094771;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tileset-dropdown-item.add-new {
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
.tileset-dropdown-divider {
|
||||
height: 1px;
|
||||
background: #3c3c3c;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ==================== Header with Zoom and Title ==================== */
|
||||
.tileset-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
background: #2a2a2a;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.tileset-zoom-label {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
margin-right: 8px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.tileset-title {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
/* ==================== Preview Area ==================== */
|
||||
.tileset-preview-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 180px;
|
||||
overflow: auto;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.tileset-empty-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tileset-select-btn {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: 1px dashed #444;
|
||||
border-radius: 3px;
|
||||
color: #4fc3f7;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tileset-select-btn:hover {
|
||||
background: rgba(79, 195, 247, 0.1);
|
||||
border-color: #4fc3f7;
|
||||
}
|
||||
|
||||
/* ==================== Selection Info ==================== */
|
||||
.tileset-selection-info {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
line-height: 24px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
background: #2d2d30;
|
||||
}
|
||||
|
||||
/* ==================== Scrollbar ==================== */
|
||||
.tileset-preview-area::-webkit-scrollbar,
|
||||
.tileset-dropdown-menu::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.tileset-preview-area::-webkit-scrollbar-track,
|
||||
.tileset-dropdown-menu::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tileset-preview-area::-webkit-scrollbar-thumb,
|
||||
.tileset-dropdown-menu::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.tileset-preview-area::-webkit-scrollbar-thumb:hover,
|
||||
.tileset-dropdown-menu::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
518
packages/tilemap/src/editor/styles/TilemapDetailsPanel.css
Normal file
518
packages/tilemap/src/editor/styles/TilemapDetailsPanel.css
Normal file
@@ -0,0 +1,518 @@
|
||||
/* ==================== Tilemap Details Panel Styles ==================== */
|
||||
|
||||
.tilemap-details-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #252526;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ==================== Header ==================== */
|
||||
.details-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
background: #2d2d30;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.details-header-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #c0c0c0;
|
||||
}
|
||||
|
||||
.details-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.details-search-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.details-search-inline svg {
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.details-search-inline input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #c0c0c0;
|
||||
font-size: 11px;
|
||||
width: 60px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.details-search-inline input::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.details-settings-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.details-settings-btn:hover {
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* ==================== Content ==================== */
|
||||
.details-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.details-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #555;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ==================== Section Styles ==================== */
|
||||
.details-section {
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.details-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #c0c0c0;
|
||||
background: #2a2a2a;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.details-section-header:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.details-section-header svg {
|
||||
color: #888;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.details-section-content {
|
||||
padding: 6px 0;
|
||||
background: #252526;
|
||||
}
|
||||
|
||||
/* ==================== Property Row ==================== */
|
||||
.property-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 26px;
|
||||
padding: 0 10px 0 20px;
|
||||
}
|
||||
|
||||
.property-row:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.property-row.indented {
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.property-row label {
|
||||
flex: 0 0 100px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.property-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ==================== Layer Count Badge ==================== */
|
||||
.layer-count-badge {
|
||||
font-size: 11px;
|
||||
color: #4fc3f7;
|
||||
background: rgba(79, 195, 247, 0.15);
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ==================== Input Styles ==================== */
|
||||
.property-row input[type="number"],
|
||||
.property-row input[type="text"],
|
||||
.property-row select {
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 2px;
|
||||
color: #c0c0c0;
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.property-row input[type="number"]:focus,
|
||||
.property-row input[type="text"]:focus,
|
||||
.property-row select:focus {
|
||||
border-color: #0078d4;
|
||||
}
|
||||
|
||||
/* ==================== Toggle Row ==================== */
|
||||
.toggle-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* ==================== Property Toggle - Unified Style ==================== */
|
||||
.property-toggle {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.property-toggle-off {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.property-toggle-off:hover:not(:disabled) {
|
||||
background: #333;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.property-toggle-on {
|
||||
background: #3b82f6;
|
||||
border: 1px solid #3b82f6;
|
||||
}
|
||||
|
||||
.property-toggle-on:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.property-toggle:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.property-toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
transition: transform 0.15s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.property-toggle-off .property-toggle-thumb {
|
||||
left: 2px;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.property-toggle-on .property-toggle-thumb {
|
||||
left: 2px;
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
/* Color Field */
|
||||
.property-color-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.property-color-preview {
|
||||
width: 32px;
|
||||
height: 22px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid #3a3a3a;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.property-color-preview:hover {
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.property-input-color {
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.property-input-color-text {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.property-input {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
padding: 0 6px;
|
||||
color: #ddd;
|
||||
font-size: 11px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.property-input:hover {
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.property-input:focus {
|
||||
outline: none;
|
||||
border-color: #d4a029;
|
||||
}
|
||||
|
||||
/* ==================== Asset Dropdown Button ==================== */
|
||||
.asset-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 2px;
|
||||
color: #c0c0c0;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.asset-dropdown:hover {
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.asset-dropdown span {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-dropdown svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* ==================== Layer List ==================== */
|
||||
.layer-list-container {
|
||||
margin: 4px 10px 4px 20px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 2px;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.layer-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
color: #c0c0c0;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.layer-list-item:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.layer-list-item.selected {
|
||||
background: #094771;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.layer-list-item .layer-icon {
|
||||
font-size: 9px;
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
/* ==================== Layer Visibility Button ==================== */
|
||||
.layer-visibility-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: #4fc3f7;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layer-visibility-btn:hover {
|
||||
background: rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
.layer-visibility-btn.hidden {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.layer-visibility-btn.hidden:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.layer-list-item.selected .layer-visibility-btn {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.layer-list-item.selected .layer-visibility-btn.hidden {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* ==================== Layer Action Buttons ==================== */
|
||||
.layer-actions-row {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 4px 10px 6px 20px;
|
||||
}
|
||||
|
||||
.layer-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: #2d2d30;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 2px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.layer-action-btn svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.layer-action-btn:hover:not(:disabled) {
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.layer-action-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.layer-action-btn.add {
|
||||
background: #1a3a1a;
|
||||
border-color: #2a5a2a;
|
||||
color: #5cb85c;
|
||||
}
|
||||
|
||||
.layer-action-btn.add:hover {
|
||||
background: #2a5a2a;
|
||||
color: #7cd87c;
|
||||
}
|
||||
|
||||
.layer-action-btn.danger:hover:not(:disabled) {
|
||||
background: #5a2020;
|
||||
border-color: #7a3030;
|
||||
color: #f06060;
|
||||
}
|
||||
|
||||
/* ==================== Selected Layer Name ==================== */
|
||||
.selected-layer-name {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #c0c0c0;
|
||||
}
|
||||
|
||||
/* ==================== Select Dropdown ==================== */
|
||||
.property-row select {
|
||||
appearance: none;
|
||||
padding-right: 20px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 6px center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ==================== Scrollbar ==================== */
|
||||
.details-content::-webkit-scrollbar,
|
||||
.layer-list-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.details-content::-webkit-scrollbar-track,
|
||||
.layer-list-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.details-content::-webkit-scrollbar-thumb,
|
||||
.layer-list-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.details-content::-webkit-scrollbar-thumb:hover,
|
||||
.layer-list-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
@@ -1,496 +1,320 @@
|
||||
/* Tilemap Editor Styles */
|
||||
/* ==================== Tilemap Editor Styles ==================== */
|
||||
|
||||
/* Main Panel - 3-Column Layout */
|
||||
.tilemap-editor-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--panel-bg, #1e1e1e);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
background: #1e1e1e;
|
||||
color: #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);
|
||||
/* ==================== Panel Divider ==================== */
|
||||
.panel-divider {
|
||||
flex-shrink: 0;
|
||||
background: #1a1a1a;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.tilemap-editor-toolbar .tool-group {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
.panel-divider.horizontal {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.tilemap-editor-toolbar .tool-btn {
|
||||
.panel-divider.vertical {
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.panel-divider:hover {
|
||||
background: #0078d4;
|
||||
}
|
||||
|
||||
.panel-divider:active {
|
||||
background: #005a9e;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.tilemap-editor-panel .tilemap-editor-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 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;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Main content layout */
|
||||
.tilemap-editor-content {
|
||||
display: flex;
|
||||
.tilemap-editor-panel .tilemap-editor-empty svg {
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.tilemap-editor-panel .tilemap-editor-empty h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.tilemap-editor-panel .tilemap-editor-empty p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ==================== Center Viewport ==================== */
|
||||
.tilemap-viewport {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: #1a1a1a;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ==================== Viewport Toolbar - 优化样式 ==================== */
|
||||
.viewport-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 6px;
|
||||
background: #2d2d30;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.viewport-toolbar-left,
|
||||
.viewport-toolbar-center,
|
||||
.viewport-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.viewport-toolbar-center {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.viewport-btn-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
background: #252526;
|
||||
border-radius: 3px;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.viewport-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: #a0a0a0;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewport-btn:hover {
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.viewport-btn.active {
|
||||
background: #094771;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.viewport-btn.icon {
|
||||
width: 24px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.viewport-btn.snap-btn {
|
||||
padding: 0 6px;
|
||||
gap: 2px;
|
||||
font-size: 10px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.viewport-btn.snap-btn svg {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.viewport-separator {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: #3c3c3c;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.zoom-display {
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #a0a0a0;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
/* Exit fullscreen button */
|
||||
.viewport-btn.exit-fullscreen {
|
||||
background: linear-gradient(180deg, #e05a50 0%, #c0443a 100%);
|
||||
color: #fff;
|
||||
padding: 0 10px;
|
||||
gap: 4px;
|
||||
font-weight: 500;
|
||||
border: 1px solid #a03028;
|
||||
}
|
||||
|
||||
.viewport-btn.exit-fullscreen:hover {
|
||||
background: linear-gradient(180deg, #f06a60 0%, #d0544a 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ==================== Info Overlay ==================== */
|
||||
.viewport-info-overlay {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 8px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 6px 8px;
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewport-info-overlay .info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #888;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.viewport-info-overlay .info-item strong {
|
||||
color: #c0c0c0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.viewport-info-overlay .info-item.warning {
|
||||
color: #e6a700;
|
||||
}
|
||||
|
||||
.viewport-info-overlay .info-item.warning svg {
|
||||
flex-shrink: 0;
|
||||
color: #e6a700;
|
||||
}
|
||||
|
||||
/* ==================== Canvas Container ==================== */
|
||||
.viewport-canvas-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
background:
|
||||
linear-gradient(45deg, #1a1a1a 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #1a1a1a 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #1a1a1a 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #1a1a1a 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
.tilemap-canvas-container {
|
||||
flex: 1;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
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;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layer-actions {
|
||||
/* ==================== Ruler ==================== */
|
||||
.viewport-ruler {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
align-items: flex-end;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.layer-item:hover .layer-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.layer-opacity-control {
|
||||
.ruler-marker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--border-color, #3c3c3c);
|
||||
font-size: 11px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.layer-opacity-control label {
|
||||
color: var(--text-secondary, #999);
|
||||
white-space: nowrap;
|
||||
min-width: 50px;
|
||||
.ruler-line {
|
||||
width: 100px;
|
||||
height: 2px;
|
||||
background: #555;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.layer-opacity-control input[type="range"] {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
min-width: 60px;
|
||||
.ruler-line::before,
|
||||
.ruler-line::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
width: 2px;
|
||||
height: 10px;
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.layer-opacity-control span {
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
color: var(--text-secondary, #999);
|
||||
white-space: nowrap;
|
||||
.ruler-line::before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Icon buttons */
|
||||
.ruler-line::after {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.ruler-marker span {
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* ==================== Watermark ==================== */
|
||||
.viewport-watermark {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.08);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* ==================== Icon Buttons ==================== */
|
||||
.icon-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -498,16 +322,16 @@
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #999);
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background: var(--hover-bg, #3c3c3c);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.icon-button:disabled {
|
||||
@@ -521,11 +345,11 @@
|
||||
}
|
||||
|
||||
.icon-button.danger:hover {
|
||||
background: var(--error-bg, #5a1d1d);
|
||||
color: var(--error-color, #f48771);
|
||||
background: #5a1d1d;
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
/* Asset Picker Dialog */
|
||||
/* ==================== Asset Picker Dialog ==================== */
|
||||
.asset-picker-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -542,8 +366,8 @@
|
||||
.asset-picker-dialog {
|
||||
width: 500px;
|
||||
max-height: 600px;
|
||||
background: var(--panel-bg, #1e1e1e);
|
||||
border: 1px solid var(--border-color, #3c3c3c);
|
||||
background: #252526;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -555,14 +379,14 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color, #3c3c3c);
|
||||
border-bottom: 1px solid #3c3c3c;
|
||||
}
|
||||
|
||||
.asset-picker-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.asset-picker-close {
|
||||
@@ -574,13 +398,13 @@
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #999);
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.asset-picker-close:hover {
|
||||
background: var(--hover-bg, #3c3c3c);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.asset-picker-search {
|
||||
@@ -588,20 +412,21 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--border-color, #3c3c3c);
|
||||
border-bottom: 1px solid #3c3c3c;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.asset-picker-search input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
color: #e0e0e0;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.asset-picker-search input::placeholder {
|
||||
color: var(--text-secondary, #999);
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.asset-picker-content {
|
||||
@@ -623,21 +448,21 @@
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.asset-picker-item:hover {
|
||||
background: var(--hover-bg, #2a2a2a);
|
||||
background: #2d2d30;
|
||||
}
|
||||
|
||||
.asset-picker-item.selected {
|
||||
background: var(--selection-bg, #094771);
|
||||
background: #094771;
|
||||
}
|
||||
|
||||
.asset-picker-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-secondary, #999);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.asset-picker-item-name {
|
||||
@@ -653,7 +478,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-secondary, #999);
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -662,13 +487,13 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border-color, #3c3c3c);
|
||||
border-top: 1px solid #3c3c3c;
|
||||
}
|
||||
|
||||
.asset-picker-selected {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #999);
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -692,21 +517,21 @@
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-cancel {
|
||||
background: var(--hover-bg, #3c3c3c);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-cancel:hover {
|
||||
background: var(--border-color, #4c4c4c);
|
||||
background: #4c4c4c;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-confirm {
|
||||
background: var(--accent-color, #0078d4);
|
||||
color: white;
|
||||
background: #0078d4;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-confirm:hover:not(:disabled) {
|
||||
background: var(--accent-hover, #106ebe);
|
||||
background: #106ebe;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-confirm:disabled {
|
||||
@@ -714,28 +539,26 @@
|
||||
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);
|
||||
background: #252526;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
pointer-events: none;
|
||||
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;
|
||||
}
|
||||
|
||||
/* ==================== Tileset Canvas ==================== */
|
||||
.tileset-canvas {
|
||||
cursor: crosshair;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
* ECS框架的瓦片地图系统
|
||||
*/
|
||||
|
||||
// Asset type constants for tilemap
|
||||
// 瓦片地图资产类型常量
|
||||
export const TilemapAssetType = 'tilemap' as const;
|
||||
export const TilesetAssetType = 'tileset' as const;
|
||||
|
||||
// Component
|
||||
export { TilemapComponent } from './TilemapComponent';
|
||||
export type { ITilemapData, ITilesetData } from './TilemapComponent';
|
||||
@@ -12,6 +17,12 @@ export type { ResizeAnchor } from './TilemapComponent';
|
||||
export { TilemapRenderingSystem } from './systems/TilemapRenderingSystem';
|
||||
export type { TilemapRenderData, ViewportBounds } from './systems/TilemapRenderingSystem';
|
||||
|
||||
// Physics
|
||||
export { TilemapCollider2DComponent, TilemapColliderMode } from './physics/TilemapCollider2DComponent';
|
||||
export type { CollisionRect } from './physics/TilemapCollider2DComponent';
|
||||
export { TilemapPhysicsSystem } from './physics/TilemapPhysicsSystem';
|
||||
export type { IPhysicsWorld, IPhysics2DSystem } from './physics/TilemapPhysicsSystem';
|
||||
|
||||
// Loaders
|
||||
export { TilemapLoader } from './loaders/TilemapLoader';
|
||||
export type { ITilemapAsset } from './loaders/TilemapLoader';
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetType,
|
||||
IAssetLoadOptions,
|
||||
IAssetMetadata,
|
||||
IAssetLoadResult,
|
||||
AssetLoadError,
|
||||
IAssetLoader
|
||||
} from '@esengine/asset-system';
|
||||
import { TilemapAssetType } from '../index';
|
||||
|
||||
/**
|
||||
* Tilemap data interface
|
||||
@@ -51,7 +51,7 @@ export interface ITilemapAsset {
|
||||
* 瓦片地图加载器实现
|
||||
*/
|
||||
export class TilemapLoader implements IAssetLoader<ITilemapAsset> {
|
||||
readonly supportedType = AssetType.Tilemap;
|
||||
readonly supportedType = TilemapAssetType;
|
||||
readonly supportedExtensions = ['.tilemap.json', '.tilemap'];
|
||||
|
||||
/**
|
||||
@@ -90,7 +90,7 @@ export class TilemapLoader implements IAssetLoader<ITilemapAsset> {
|
||||
throw new AssetLoadError(
|
||||
`Failed to load tilemap: ${error.message}`,
|
||||
metadata.guid,
|
||||
AssetType.Tilemap,
|
||||
TilemapAssetType,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetType,
|
||||
IAssetLoadOptions,
|
||||
IAssetMetadata,
|
||||
IAssetLoadResult,
|
||||
AssetLoadError,
|
||||
IAssetLoader
|
||||
} from '@esengine/asset-system';
|
||||
import { TilesetAssetType } from '../index';
|
||||
|
||||
/**
|
||||
* Tileset data interface
|
||||
@@ -54,7 +54,7 @@ export interface ITilesetAsset {
|
||||
* 瓦片集加载器实现
|
||||
*/
|
||||
export class TilesetLoader implements IAssetLoader<ITilesetAsset> {
|
||||
readonly supportedType = AssetType.Tileset;
|
||||
readonly supportedType = TilesetAssetType;
|
||||
readonly supportedExtensions = ['.tileset.json', '.tileset'];
|
||||
|
||||
/**
|
||||
@@ -104,7 +104,7 @@ export class TilesetLoader implements IAssetLoader<ITilesetAsset> {
|
||||
throw new AssetLoadError(
|
||||
`Failed to load tileset: ${error.message}`,
|
||||
metadata.guid,
|
||||
AssetType.Tileset,
|
||||
TilesetAssetType,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
280
packages/tilemap/src/physics/TilemapCollider2DComponent.ts
Normal file
280
packages/tilemap/src/physics/TilemapCollider2DComponent.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* TilemapCollider2D Component
|
||||
* Tilemap 碰撞体组件
|
||||
*
|
||||
* 将 TilemapComponent 的碰撞数据转换为物理碰撞体。
|
||||
* 使用优化算法合并相邻碰撞格子,减少碰撞体数量。
|
||||
*/
|
||||
|
||||
import { Component, Property, Serialize, ECSComponent, Serializable } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 碰撞体生成模式
|
||||
*/
|
||||
export enum TilemapColliderMode {
|
||||
/** 每个碰撞格子单独创建碰撞体 */
|
||||
PerTile = 0,
|
||||
/** 合并相邻碰撞格子为更大的矩形 */
|
||||
Merged = 1,
|
||||
/** 只生成边缘碰撞体(优化性能) */
|
||||
EdgeOnly = 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并后的碰撞矩形
|
||||
*/
|
||||
export interface CollisionRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* TilemapCollider2D 组件
|
||||
*
|
||||
* 自动从同一实体的 TilemapComponent 读取碰撞数据,
|
||||
* 并生成对应的物理碰撞体。
|
||||
*/
|
||||
@ECSComponent('TilemapCollider2D')
|
||||
@Serializable({ version: 1, typeId: 'TilemapCollider2D' })
|
||||
export class TilemapCollider2DComponent extends Component {
|
||||
/**
|
||||
* 碰撞体生成模式
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({
|
||||
type: 'enum',
|
||||
label: 'Collider Mode',
|
||||
options: [
|
||||
{ label: 'Per Tile', value: TilemapColliderMode.PerTile },
|
||||
{ label: 'Merged', value: TilemapColliderMode.Merged },
|
||||
{ label: 'Edge Only', value: TilemapColliderMode.EdgeOnly },
|
||||
],
|
||||
})
|
||||
public colliderMode: TilemapColliderMode = TilemapColliderMode.Merged;
|
||||
|
||||
/**
|
||||
* 碰撞层(该碰撞体所在的层)
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'collisionLayer', label: 'Collision Layer' })
|
||||
public collisionLayer: number = 1; // Default layer
|
||||
|
||||
/**
|
||||
* 碰撞掩码(该碰撞体可以与哪些层碰撞)
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'collisionMask', label: 'Collision Mask' })
|
||||
public collisionMask: number = 0xFFFF; // All layers
|
||||
|
||||
/**
|
||||
* 摩擦系数
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Friction', min: 0, max: 1, step: 0.01 })
|
||||
public friction: number = 0.5;
|
||||
|
||||
/**
|
||||
* 弹性系数
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Restitution', min: 0, max: 1, step: 0.01 })
|
||||
public restitution: number = 0;
|
||||
|
||||
/**
|
||||
* 是否为触发器
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Is Trigger' })
|
||||
public isTrigger: boolean = false;
|
||||
|
||||
/**
|
||||
* 生成的碰撞矩形缓存
|
||||
* @internal
|
||||
*/
|
||||
public _collisionRects: CollisionRect[] = [];
|
||||
|
||||
/**
|
||||
* 碰撞体句柄列表
|
||||
* @internal
|
||||
*/
|
||||
public _colliderHandles: number[] = [];
|
||||
|
||||
/**
|
||||
* 是否需要重建碰撞体
|
||||
* @internal
|
||||
*/
|
||||
public _needsRebuild: boolean = true;
|
||||
|
||||
/**
|
||||
* 碰撞数据版本(用于检测变化)
|
||||
* @internal
|
||||
*/
|
||||
public _lastCollisionVersion: number = -1;
|
||||
|
||||
/**
|
||||
* 从碰撞数据生成碰撞矩形
|
||||
* @param collisionData 碰撞数据数组
|
||||
* @param width 地图宽度(格子数)
|
||||
* @param height 地图高度(格子数)
|
||||
* @param tileWidth 格子宽度(像素)
|
||||
* @param tileHeight 格子高度(像素)
|
||||
*/
|
||||
public generateCollisionRects(
|
||||
collisionData: Uint32Array,
|
||||
width: number,
|
||||
height: number,
|
||||
tileWidth: number,
|
||||
tileHeight: number
|
||||
): CollisionRect[] {
|
||||
if (collisionData.length === 0) {
|
||||
this._collisionRects = [];
|
||||
return [];
|
||||
}
|
||||
|
||||
switch (this.colliderMode) {
|
||||
case TilemapColliderMode.PerTile:
|
||||
this._collisionRects = this._generatePerTileRects(
|
||||
collisionData, width, height, tileWidth, tileHeight
|
||||
);
|
||||
break;
|
||||
case TilemapColliderMode.Merged:
|
||||
this._collisionRects = this._generateMergedRects(
|
||||
collisionData, width, height, tileWidth, tileHeight
|
||||
);
|
||||
break;
|
||||
case TilemapColliderMode.EdgeOnly:
|
||||
// Edge-only 模式暂时使用合并模式
|
||||
this._collisionRects = this._generateMergedRects(
|
||||
collisionData, width, height, tileWidth, tileHeight
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
this._needsRebuild = true;
|
||||
return this._collisionRects;
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个格子单独生成矩形
|
||||
*/
|
||||
private _generatePerTileRects(
|
||||
collisionData: Uint32Array,
|
||||
width: number,
|
||||
height: number,
|
||||
tileWidth: number,
|
||||
tileHeight: number
|
||||
): CollisionRect[] {
|
||||
const rects: CollisionRect[] = [];
|
||||
|
||||
for (let row = 0; row < height; row++) {
|
||||
for (let col = 0; col < width; col++) {
|
||||
if (collisionData[row * width + col] > 0) {
|
||||
rects.push({
|
||||
x: col * tileWidth,
|
||||
y: row * tileHeight,
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rects;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并相邻格子生成更大的矩形(贪心算法)
|
||||
*
|
||||
* 使用行优先扫描,合并水平相邻的碰撞格子,
|
||||
* 然后尝试垂直合并相同宽度的矩形。
|
||||
*/
|
||||
private _generateMergedRects(
|
||||
collisionData: Uint32Array,
|
||||
width: number,
|
||||
height: number,
|
||||
tileWidth: number,
|
||||
tileHeight: number
|
||||
): CollisionRect[] {
|
||||
// 创建已处理标记数组
|
||||
const processed = new Array(width * height).fill(false);
|
||||
const rects: CollisionRect[] = [];
|
||||
|
||||
for (let row = 0; row < height; row++) {
|
||||
for (let col = 0; col < width; col++) {
|
||||
const index = row * width + col;
|
||||
|
||||
// 跳过已处理或无碰撞的格子
|
||||
if (processed[index] || collisionData[index] === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 找到水平方向最大范围
|
||||
let endCol = col;
|
||||
while (
|
||||
endCol < width &&
|
||||
collisionData[row * width + endCol] > 0 &&
|
||||
!processed[row * width + endCol]
|
||||
) {
|
||||
endCol++;
|
||||
}
|
||||
const rectWidth = endCol - col;
|
||||
|
||||
// 找到垂直方向最大范围(保持相同宽度)
|
||||
let endRow = row;
|
||||
let canExtend = true;
|
||||
while (canExtend && endRow < height) {
|
||||
// 检查这一行是否都有碰撞且未处理
|
||||
for (let c = col; c < endCol; c++) {
|
||||
const idx = endRow * width + c;
|
||||
if (collisionData[idx] === 0 || processed[idx]) {
|
||||
canExtend = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (canExtend) {
|
||||
endRow++;
|
||||
}
|
||||
}
|
||||
const rectHeight = endRow - row;
|
||||
|
||||
// 标记所有包含的格子为已处理
|
||||
for (let r = row; r < endRow; r++) {
|
||||
for (let c = col; c < endCol; c++) {
|
||||
processed[r * width + c] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加合并后的矩形
|
||||
rects.push({
|
||||
x: col * tileWidth,
|
||||
y: row * tileHeight,
|
||||
width: rectWidth * tileWidth,
|
||||
height: rectHeight * tileHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rects;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取碰撞矩形数量
|
||||
*/
|
||||
public getCollisionRectCount(): number {
|
||||
return this._collisionRects.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记需要重建
|
||||
*/
|
||||
public markNeedsRebuild(): void {
|
||||
this._needsRebuild = true;
|
||||
}
|
||||
|
||||
public override onRemovedFromEntity(): void {
|
||||
this._collisionRects = [];
|
||||
this._colliderHandles = [];
|
||||
}
|
||||
}
|
||||
186
packages/tilemap/src/physics/TilemapPhysicsSystem.ts
Normal file
186
packages/tilemap/src/physics/TilemapPhysicsSystem.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* TilemapPhysicsSystem
|
||||
* Tilemap 物理系统
|
||||
*
|
||||
* 负责将 TilemapComponent 的碰撞数据同步到物理世界。
|
||||
* 需要与 Physics2DSystem 配合使用。
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, ECSSystem, type Entity, type Scene } from '@esengine/ecs-framework';
|
||||
import { TransformComponent } from '@esengine/ecs-components';
|
||||
import { TilemapComponent } from '../TilemapComponent';
|
||||
import { TilemapCollider2DComponent, type CollisionRect } from './TilemapCollider2DComponent';
|
||||
|
||||
/**
|
||||
* 物理世界接口(避免直接依赖 physics-rapier2d)
|
||||
*/
|
||||
export interface IPhysicsWorld {
|
||||
createStaticCollider(
|
||||
entityId: number,
|
||||
position: { x: number; y: number },
|
||||
halfExtents: { x: number; y: number },
|
||||
collisionLayer: number,
|
||||
collisionMask: number,
|
||||
friction: number,
|
||||
restitution: number,
|
||||
isTrigger: boolean
|
||||
): number | null;
|
||||
removeCollider(handle: number): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 物理系统接口
|
||||
*/
|
||||
export interface IPhysics2DSystem {
|
||||
world: IPhysicsWorld;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tilemap 物理系统
|
||||
*
|
||||
* 监听带有 TilemapComponent 和 TilemapCollider2DComponent 的实体,
|
||||
* 自动将碰撞数据转换为物理碰撞体。
|
||||
*/
|
||||
@ECSSystem('TilemapPhysics', { updateOrder: 50 })
|
||||
export class TilemapPhysicsSystem extends EntitySystem {
|
||||
private _physicsWorld: IPhysicsWorld | null = null;
|
||||
private _pendingEntities: Entity[] = [];
|
||||
|
||||
constructor() {
|
||||
super(Matcher.empty().all(TilemapComponent, TilemapCollider2DComponent));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置物理世界引用
|
||||
*/
|
||||
public setPhysicsWorld(world: IPhysicsWorld): void {
|
||||
this._physicsWorld = world;
|
||||
|
||||
// 处理待处理的实体
|
||||
for (const entity of this._pendingEntities) {
|
||||
this._createColliders(entity);
|
||||
}
|
||||
this._pendingEntities = [];
|
||||
}
|
||||
|
||||
protected override onAdded(entity: Entity): void {
|
||||
if (!this._physicsWorld) {
|
||||
this._pendingEntities.push(entity);
|
||||
return;
|
||||
}
|
||||
this._createColliders(entity);
|
||||
}
|
||||
|
||||
protected override onRemoved(entity: Entity): void {
|
||||
this._removeColliders(entity);
|
||||
|
||||
const idx = this._pendingEntities.indexOf(entity);
|
||||
if (idx >= 0) {
|
||||
this._pendingEntities.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
if (!this._physicsWorld) return;
|
||||
|
||||
for (const entity of entities) {
|
||||
const tilemap = entity.getComponent(TilemapComponent);
|
||||
const collider = entity.getComponent(TilemapCollider2DComponent);
|
||||
|
||||
if (!tilemap || !collider) continue;
|
||||
|
||||
// 检查碰撞数据是否变化
|
||||
const currentVersion = tilemap.renderDirty ? Date.now() : collider._lastCollisionVersion;
|
||||
if (collider._needsRebuild || currentVersion !== collider._lastCollisionVersion) {
|
||||
this._rebuildColliders(entity);
|
||||
collider._lastCollisionVersion = currentVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建碰撞体
|
||||
*/
|
||||
private _createColliders(entity: Entity): void {
|
||||
const tilemap = entity.getComponent(TilemapComponent);
|
||||
const collider = entity.getComponent(TilemapCollider2DComponent);
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
|
||||
if (!tilemap || !collider || !this._physicsWorld) return;
|
||||
|
||||
// 生成碰撞矩形
|
||||
const collisionData = tilemap.collisionData;
|
||||
collider.generateCollisionRects(
|
||||
collisionData,
|
||||
tilemap.width,
|
||||
tilemap.height,
|
||||
tilemap.tileWidth,
|
||||
tilemap.tileHeight
|
||||
);
|
||||
|
||||
// 获取实体位置偏移
|
||||
const offsetX = transform?.position.x ?? 0;
|
||||
const offsetY = transform?.position.y ?? 0;
|
||||
|
||||
// 计算地图总高度(像素),用于 Y 轴翻转
|
||||
// Calculate total map height (pixels) for Y-axis flip
|
||||
const mapPixelHeight = tilemap.height * tilemap.tileHeight;
|
||||
|
||||
// 为每个碰撞矩形创建物理碰撞体
|
||||
for (const rect of collider._collisionRects) {
|
||||
// Y 轴翻转:rect.y 是从顶部计算的,需要翻转到底部
|
||||
// Y-axis flip: rect.y is calculated from top, needs flip to bottom
|
||||
const flippedY = mapPixelHeight - rect.y - rect.height;
|
||||
|
||||
const handle = this._physicsWorld.createStaticCollider(
|
||||
entity.id,
|
||||
{
|
||||
x: offsetX + rect.x + rect.width / 2,
|
||||
y: offsetY + flippedY + rect.height / 2,
|
||||
},
|
||||
{
|
||||
x: rect.width / 2,
|
||||
y: rect.height / 2,
|
||||
},
|
||||
collider.collisionLayer,
|
||||
collider.collisionMask,
|
||||
collider.friction,
|
||||
collider.restitution,
|
||||
collider.isTrigger
|
||||
);
|
||||
|
||||
if (handle !== null) {
|
||||
collider._colliderHandles.push(handle);
|
||||
}
|
||||
}
|
||||
|
||||
collider._needsRebuild = false;
|
||||
this.logger.debug(`Created ${collider._colliderHandles.length} colliders for tilemap entity ${entity.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除碰撞体
|
||||
*/
|
||||
private _removeColliders(entity: Entity): void {
|
||||
const collider = entity.getComponent(TilemapCollider2DComponent);
|
||||
if (!collider || !this._physicsWorld) return;
|
||||
|
||||
for (const handle of collider._colliderHandles) {
|
||||
this._physicsWorld.removeCollider(handle);
|
||||
}
|
||||
collider._colliderHandles = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建碰撞体
|
||||
*/
|
||||
private _rebuildColliders(entity: Entity): void {
|
||||
this._removeColliders(entity);
|
||||
this._createColliders(entity);
|
||||
}
|
||||
|
||||
protected override onDestroy(): void {
|
||||
this._physicsWorld = null;
|
||||
this._pendingEntities = [];
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,21 @@
|
||||
* 用于独立游戏运行时构建。
|
||||
*/
|
||||
|
||||
// Component
|
||||
// Components
|
||||
export { TilemapComponent } from './TilemapComponent';
|
||||
export type { ITilemapData, ITilesetData } from './TilemapComponent';
|
||||
export type { ResizeAnchor } from './TilemapComponent';
|
||||
|
||||
export { TilemapCollider2DComponent, TilemapColliderMode } from './physics/TilemapCollider2DComponent';
|
||||
export type { CollisionRect } from './physics/TilemapCollider2DComponent';
|
||||
|
||||
// Systems
|
||||
export { TilemapRenderingSystem } from './systems/TilemapRenderingSystem';
|
||||
export type { TilemapRenderData, ViewportBounds } from './systems/TilemapRenderingSystem';
|
||||
|
||||
export { TilemapPhysicsSystem } from './physics/TilemapPhysicsSystem';
|
||||
export type { IPhysicsWorld, IPhysics2DSystem } from './physics/TilemapPhysicsSystem';
|
||||
|
||||
// Loaders
|
||||
export { TilemapLoader } from './loaders/TilemapLoader';
|
||||
export type { ITilemapAsset } from './loaders/TilemapLoader';
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../editor-core"
|
||||
},
|
||||
{
|
||||
"path": "../asset-system"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,51 +3,60 @@ import { resolve } from 'path';
|
||||
import dts from 'vite-plugin-dts';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// 自定义插件:将 CSS 内联到 JS 中
|
||||
function inlineCSS(): any {
|
||||
/**
|
||||
* 自定义插件:将 CSS 转换为自执行的样式注入代码
|
||||
* Custom plugin: Convert CSS to self-executing style injection code
|
||||
*
|
||||
* 当用户写 `import './styles.css'` 时,这个插件会:
|
||||
* 1. 在构建时将 CSS 内容转换为 JS 代码
|
||||
* 2. JS 代码在模块导入时自动执行,将样式注入到 DOM
|
||||
* 3. 使用唯一 ID 防止重复注入
|
||||
*
|
||||
* When user writes `import './styles.css'`, this plugin will:
|
||||
* 1. Convert CSS content to JS code during build
|
||||
* 2. JS code auto-executes when module is imported, injecting styles to DOM
|
||||
* 3. Uses unique ID to prevent duplicate injection
|
||||
*/
|
||||
function injectCSSPlugin(): any {
|
||||
const cssIdMap = new Map<string, string>();
|
||||
let cssCounter = 0;
|
||||
|
||||
return {
|
||||
name: 'inline-css',
|
||||
name: 'inject-css-plugin',
|
||||
enforce: 'post' as const,
|
||||
// 在生成 bundle 时注入 CSS
|
||||
generateBundle(_options: any, bundle: any) {
|
||||
const bundleKeys = Object.keys(bundle);
|
||||
|
||||
// 找到 CSS 文件
|
||||
const cssFile = bundleKeys.find(key => key.endsWith('.css'));
|
||||
if (!cssFile || !bundle[cssFile]) {
|
||||
return;
|
||||
// 找到所有 CSS 文件
|
||||
const cssFiles = bundleKeys.filter(key => key.endsWith('.css'));
|
||||
|
||||
for (const cssFile of cssFiles) {
|
||||
const cssChunk = bundle[cssFile];
|
||||
if (!cssChunk || !cssChunk.source) continue;
|
||||
|
||||
const cssContent = cssChunk.source;
|
||||
const styleId = `esengine-tilemap-style-${cssCounter++}`;
|
||||
cssIdMap.set(cssFile, styleId);
|
||||
|
||||
// 生成样式注入代码
|
||||
const injectCode = `(function(){if(typeof document!=='undefined'){var s=document.createElement('style');s.id='${styleId}';if(!document.getElementById(s.id)){s.textContent=${JSON.stringify(cssContent)};document.head.appendChild(s);}}})();`;
|
||||
|
||||
// 找到引用此 CSS 的 JS chunk 并注入代码
|
||||
for (const jsKey of bundleKeys) {
|
||||
if (!jsKey.endsWith('.js')) continue;
|
||||
const jsChunk = bundle[jsKey];
|
||||
if (!jsChunk || jsChunk.type !== 'chunk' || !jsChunk.code) continue;
|
||||
|
||||
// 检查是否引用了这个 CSS(通过检查是否有相关的 import)
|
||||
// 对于 vite 生成的代码,CSS 导入会被转换,所以我们直接注入到 editor/index.js
|
||||
if (jsKey === 'editor/index.js' || jsKey.match(/^index-[^/]+\.js$/)) {
|
||||
jsChunk.code = injectCode + '\n' + jsChunk.code;
|
||||
}
|
||||
}
|
||||
|
||||
// 删除独立的 CSS 文件
|
||||
delete bundle[cssFile];
|
||||
}
|
||||
|
||||
const cssContent = bundle[cssFile].source;
|
||||
if (!cssContent) return;
|
||||
|
||||
// 找到包含编辑器代码的主要 JS 文件
|
||||
// 优先查找 editor/index.js,然后是带 hash 的 index-*.js
|
||||
const mainJsFile = bundleKeys.find(key =>
|
||||
(key === 'editor/index.js' || key.includes('index-')) &&
|
||||
key.endsWith('.js') &&
|
||||
bundle[key].type === 'chunk' &&
|
||||
bundle[key].code
|
||||
);
|
||||
|
||||
if (mainJsFile && bundle[mainJsFile]) {
|
||||
const injectCode = `
|
||||
(function() {
|
||||
if (typeof document !== 'undefined') {
|
||||
var style = document.createElement('style');
|
||||
style.id = 'esengine-tilemap-styles';
|
||||
if (!document.getElementById(style.id)) {
|
||||
style.textContent = ${JSON.stringify(cssContent)};
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
})();
|
||||
`;
|
||||
bundle[mainJsFile].code = injectCode + bundle[mainJsFile].code;
|
||||
}
|
||||
|
||||
// 删除独立的 CSS 文件(已内联)
|
||||
delete bundle[cssFile];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -60,7 +69,7 @@ export default defineConfig({
|
||||
outDir: 'dist',
|
||||
rollupTypes: false
|
||||
}),
|
||||
inlineCSS()
|
||||
injectCSSPlugin()
|
||||
],
|
||||
esbuild: {
|
||||
jsx: 'automatic',
|
||||
|
||||
Reference in New Issue
Block a user