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:
YHH
2025-12-01 22:28:51 +08:00
committed by GitHub
parent 189714c727
commit b42a7b4e43
468 changed files with 18301 additions and 9075 deletions

View File

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

View File

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