Feature/editor optimization (#251)
* refactor: 编辑器/运行时架构拆分与构建系统升级 * feat(core): 层级系统重构与UI变换矩阵修复 * refactor: 移除 ecs-components 聚合包并修复跨包组件查找问题 * fix(physics): 修复跨包组件类引用问题 * feat: 统一运行时架构与浏览器运行支持 * feat(asset): 实现浏览器运行时资产加载系统 * fix: 修复文档、CodeQL安全问题和CI类型检查错误 * fix: 修复文档、CodeQL安全问题和CI类型检查错误 * fix: 修复文档、CodeQL安全问题、CI类型检查和测试错误 * test: 补齐核心模块测试用例,修复CI构建配置 * fix: 修复测试用例中的类型错误和断言问题 * fix: 修复 turbo build:npm 任务的依赖顺序问题 * fix: 修复 CI 构建错误并优化构建性能
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Collision Layer Selector Component
|
||||
* 碰撞层选择器组件
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { CollisionLayerConfig } from '@esengine/physics-rapier2d';
|
||||
|
||||
interface CollisionLayerSelectorProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
label?: string;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
export const CollisionLayerSelector: React.FC<CollisionLayerSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
multiple = false
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [layers, setLayers] = useState(CollisionLayerConfig.getInstance().getLayers());
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const config = CollisionLayerConfig.getInstance();
|
||||
|
||||
useEffect(() => {
|
||||
const handleUpdate = () => {
|
||||
setLayers([...config.getLayers()]);
|
||||
};
|
||||
|
||||
config.addListener(handleUpdate);
|
||||
return () => config.removeListener(handleUpdate);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const getSelectedLayerNames = (): string => {
|
||||
if (!multiple) {
|
||||
const index = config.getLayerIndex(value);
|
||||
return layers[index]?.name ?? 'Default';
|
||||
}
|
||||
|
||||
const selectedNames: string[] = [];
|
||||
for (let i = 0; i < 16; i++) {
|
||||
if ((value & (1 << i)) !== 0) {
|
||||
selectedNames.push(layers[i]?.name ?? `Layer${i}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedNames.length === 0) return 'None';
|
||||
if (selectedNames.length === 16) return 'All';
|
||||
if (selectedNames.length > 3) return `${selectedNames.length} layers`;
|
||||
return selectedNames.join(', ');
|
||||
};
|
||||
|
||||
const handleLayerToggle = (index: number) => {
|
||||
if (multiple) {
|
||||
const bit = 1 << index;
|
||||
const newValue = (value & bit) ? (value & ~bit) : (value | bit);
|
||||
onChange(newValue);
|
||||
} else {
|
||||
onChange(1 << index);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
onChange(0xFFFF);
|
||||
};
|
||||
|
||||
const handleSelectNone = () => {
|
||||
onChange(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="collision-layer-selector" ref={dropdownRef}>
|
||||
{label && <label className="selector-label">{label}</label>}
|
||||
<div className="selector-container">
|
||||
<button
|
||||
className="selector-button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
type="button"
|
||||
>
|
||||
<span className="selected-text">{getSelectedLayerNames()}</span>
|
||||
<span className="dropdown-arrow">{isOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="dropdown-menu">
|
||||
{multiple && (
|
||||
<div className="dropdown-actions">
|
||||
<button onClick={handleSelectAll} type="button">全选</button>
|
||||
<button onClick={handleSelectNone} type="button">全不选</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="dropdown-list">
|
||||
{layers.slice(0, 8).map((layer, index) => {
|
||||
const isSelected = multiple
|
||||
? (value & (1 << index)) !== 0
|
||||
: config.getLayerIndex(value) === index;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`dropdown-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => handleLayerToggle(index)}
|
||||
>
|
||||
{multiple && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
className="layer-checkbox"
|
||||
/>
|
||||
)}
|
||||
<span className="layer-index">{index}</span>
|
||||
<span className="layer-name">{layer.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.collision-layer-selector {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selector-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #aaa);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.selector-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selector-button {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary, #1a1a1a);
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.selector-button:hover {
|
||||
border-color: var(--accent-color, #0078d4);
|
||||
}
|
||||
|
||||
.selected-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: 8px;
|
||||
margin-left: 8px;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 2px;
|
||||
background: var(--bg-secondary, #1e1e1e);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
max-height: 250px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dropdown-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.dropdown-actions button {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--text-secondary, #aaa);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-actions button:hover {
|
||||
background: var(--bg-hover, #444);
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
.dropdown-list {
|
||||
overflow-y: auto;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--bg-hover, #2a2a2a);
|
||||
}
|
||||
|
||||
.dropdown-item.selected {
|
||||
background: var(--accent-color-dim, rgba(0, 120, 212, 0.2));
|
||||
}
|
||||
|
||||
.layer-checkbox {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--accent-color, #0078d4);
|
||||
}
|
||||
|
||||
.layer-index {
|
||||
width: 20px;
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary, #666);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollisionLayerSelector;
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Collision Matrix Editor Component
|
||||
* 碰撞矩阵编辑器组件
|
||||
*
|
||||
* 用于配置物理层之间的碰撞关系,支持 16 个碰撞层。
|
||||
* 使用三角形矩阵布局,双击行标题可编辑层名称。
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { CollisionLayerConfig } from '@esengine/physics-rapier2d';
|
||||
|
||||
interface CollisionMatrixEditorProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const CollisionMatrixEditor: React.FC<CollisionMatrixEditorProps> = ({ onClose }) => {
|
||||
const [layers, setLayers] = useState(CollisionLayerConfig.getInstance().getLayers());
|
||||
const [editingLayer, setEditingLayer] = useState<number | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [hoverCell, setHoverCell] = useState<{ row: number; col: number } | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const config = CollisionLayerConfig.getInstance();
|
||||
|
||||
useEffect(() => {
|
||||
const handleUpdate = () => {
|
||||
setLayers([...config.getLayers()]);
|
||||
};
|
||||
config.addListener(handleUpdate);
|
||||
return () => config.removeListener(handleUpdate);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingLayer !== null && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [editingLayer]);
|
||||
|
||||
const handleToggleCollision = useCallback((layerA: number, layerB: number) => {
|
||||
const canCollide = config.canLayersCollide(layerA, layerB);
|
||||
config.setLayersCanCollide(layerA, layerB, !canCollide);
|
||||
}, []);
|
||||
|
||||
const handleStartEdit = (index: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditingLayer(index);
|
||||
setEditName(layers[index]?.name ?? '');
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (editingLayer !== null && editName.trim()) {
|
||||
config.setLayerName(editingLayer, editName.trim());
|
||||
}
|
||||
setEditingLayer(null);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSaveEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingLayer(null);
|
||||
}
|
||||
};
|
||||
|
||||
const renderTriangleMatrix = () => {
|
||||
const visibleLayers = layers.slice(0, 16);
|
||||
|
||||
return (
|
||||
<div className="cmx-container">
|
||||
{/* 提示信息 */}
|
||||
<div className="cmx-hint">双击层名称可编辑</div>
|
||||
|
||||
<div className="cmx-matrix" onMouseLeave={() => setHoverCell(null)}>
|
||||
{/* 列标题 */}
|
||||
<div className="cmx-col-headers">
|
||||
{visibleLayers.map((layer, colIdx) => (
|
||||
<div
|
||||
key={colIdx}
|
||||
className={`cmx-col-label ${hoverCell?.col === colIdx ? 'highlight' : ''}`}
|
||||
style={{ left: `${72 + colIdx * 18}px` }}
|
||||
>
|
||||
<span className="cmx-col-text">{layer.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 矩阵行 */}
|
||||
<div className="cmx-rows">
|
||||
{visibleLayers.map((rowLayer, rowIdx) => (
|
||||
<div key={rowIdx} className={`cmx-row ${rowIdx % 2 === 0 ? 'even' : 'odd'}`}>
|
||||
{/* 行标题 */}
|
||||
<div
|
||||
className={`cmx-row-label ${hoverCell?.row === rowIdx ? 'highlight' : ''}`}
|
||||
onDoubleClick={(e) => handleStartEdit(rowIdx, e)}
|
||||
>
|
||||
{editingLayer === rowIdx ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onBlur={handleSaveEdit}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="cmx-edit-input"
|
||||
maxLength={16}
|
||||
/>
|
||||
) : (
|
||||
<span className="cmx-row-text">{rowLayer.name}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 单元格 */}
|
||||
<div className="cmx-cells">
|
||||
{visibleLayers.slice(0, rowIdx + 1).map((_, colIdx) => {
|
||||
const canCollide = config.canLayersCollide(rowIdx, colIdx);
|
||||
const isHovered = hoverCell?.row === rowIdx && hoverCell?.col === colIdx;
|
||||
const isHighlightRow = hoverCell?.row === rowIdx;
|
||||
const isHighlightCol = hoverCell?.col === colIdx;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={colIdx}
|
||||
className={`cmx-cell ${isHovered ? 'hovered' : ''} ${(isHighlightRow || isHighlightCol) && !isHovered ? 'highlight' : ''}`}
|
||||
onClick={() => handleToggleCollision(rowIdx, colIdx)}
|
||||
onMouseEnter={() => setHoverCell({ row: rowIdx, col: colIdx })}
|
||||
>
|
||||
<div className={`cmx-checkbox ${canCollide ? 'checked' : ''}`} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cmx-editor">
|
||||
{renderTriangleMatrix()}
|
||||
|
||||
<style>{`
|
||||
.cmx-editor {
|
||||
font-size: 11px;
|
||||
color: var(--text-primary, #ccc);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.cmx-container {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
padding-top: 75px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.cmx-hint {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary, #666);
|
||||
}
|
||||
|
||||
.cmx-matrix {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* 列标题 */
|
||||
.cmx-col-headers {
|
||||
position: absolute;
|
||||
top: -65px;
|
||||
left: 0;
|
||||
height: 65px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.cmx-col-label {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 18px;
|
||||
height: 65px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-start;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.cmx-col-label.highlight .cmx-col-text {
|
||||
color: var(--accent-color, #58a6ff);
|
||||
}
|
||||
|
||||
.cmx-col-text {
|
||||
transform-origin: left bottom;
|
||||
transform: rotate(-45deg);
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary, #8b8b8b);
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
/* 行 */
|
||||
.cmx-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.cmx-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.cmx-row.even {
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
}
|
||||
|
||||
.cmx-row-label {
|
||||
width: 72px;
|
||||
min-width: 72px;
|
||||
padding-right: 8px;
|
||||
text-align: right;
|
||||
cursor: default;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.cmx-row-label.highlight .cmx-row-text {
|
||||
color: var(--accent-color, #58a6ff);
|
||||
}
|
||||
|
||||
.cmx-row-text {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary, #8b8b8b);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cmx-edit-input {
|
||||
width: 64px;
|
||||
padding: 1px 4px;
|
||||
border: 1px solid var(--accent-color, #58a6ff);
|
||||
border-radius: 2px;
|
||||
background: var(--input-bg, #1e1e1e);
|
||||
color: var(--text-primary, #ccc);
|
||||
font-size: 10px;
|
||||
outline: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 单元格 */
|
||||
.cmx-cells {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cmx-cell {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.cmx-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.cmx-cell.highlight {
|
||||
background: rgba(88, 166, 255, 0.06);
|
||||
}
|
||||
|
||||
.cmx-cell.hovered {
|
||||
background: rgba(88, 166, 255, 0.12);
|
||||
}
|
||||
|
||||
.cmx-checkbox {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1px solid var(--border-color, #404040);
|
||||
border-radius: 2px;
|
||||
background: var(--input-bg, #1e1e1e);
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.cmx-cell:hover .cmx-checkbox {
|
||||
border-color: var(--text-tertiary, #606060);
|
||||
}
|
||||
|
||||
.cmx-checkbox.checked {
|
||||
background: var(--accent-color, #58a6ff);
|
||||
border-color: var(--accent-color, #58a6ff);
|
||||
}
|
||||
|
||||
.cmx-checkbox.checked::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 3px;
|
||||
border-left: 1.5px solid white;
|
||||
border-bottom: 1.5px solid white;
|
||||
transform: rotate(-45deg) translate(1px, 2px);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollisionMatrixEditor;
|
||||
Reference in New Issue
Block a user