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:
YHH
2025-11-29 23:00:48 +08:00
committed by GitHub
parent f03b73b58e
commit 359886c72f
198 changed files with 33879 additions and 13121 deletions

View File

@@ -1,5 +1,9 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { Play, Pause, Square, RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity, MousePointer2, Move, RotateCw, Scaling, Globe, QrCode, ChevronDown } from 'lucide-react';
import {
RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity,
MousePointer2, Move, RotateCw, Scaling, Globe, QrCode, ChevronDown,
Magnet, ZoomIn
} from 'lucide-react';
import '../styles/Viewport.css';
import { useEngine } from '../hooks/useEngine';
import { EngineService } from '../services/EngineService';
@@ -101,6 +105,18 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
const [devicePreviewUrl, setDevicePreviewUrl] = useState('');
const runMenuRef = useRef<HTMLDivElement>(null);
// Snap settings
const [snapEnabled, setSnapEnabled] = useState(true);
const [gridSnapValue, setGridSnapValue] = useState(10);
const [rotationSnapValue, setRotationSnapValue] = useState(15);
const [scaleSnapValue, setScaleSnapValue] = useState(0.25);
const [showGridSnapMenu, setShowGridSnapMenu] = useState(false);
const [showRotationSnapMenu, setShowRotationSnapMenu] = useState(false);
const [showScaleSnapMenu, setShowScaleSnapMenu] = useState(false);
const gridSnapMenuRef = useRef<HTMLDivElement>(null);
const rotationSnapMenuRef = useRef<HTMLDivElement>(null);
const scaleSnapMenuRef = useRef<HTMLDivElement>(null);
// Store editor camera state when entering play mode
const editorCameraRef = useRef({ x: 0, y: 0, zoom: 1 });
const playStateRef = useRef<PlayState>('stopped');
@@ -130,6 +146,10 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
const selectedEntityRef = useRef<Entity | null>(null);
const messageHubRef = useRef<MessageHub | null>(null);
const transformModeRef = useRef<TransformMode>('select');
const snapEnabledRef = useRef(true);
const gridSnapRef = useRef(10);
const rotationSnapRef = useRef(15);
const scaleSnapRef = useRef(0.25);
// Keep refs in sync with state
useEffect(() => {
@@ -144,6 +164,40 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
transformModeRef.current = transformMode;
}, [transformMode]);
useEffect(() => {
snapEnabledRef.current = snapEnabled;
}, [snapEnabled]);
useEffect(() => {
gridSnapRef.current = gridSnapValue;
}, [gridSnapValue]);
useEffect(() => {
rotationSnapRef.current = rotationSnapValue;
}, [rotationSnapValue]);
useEffect(() => {
scaleSnapRef.current = scaleSnapValue;
}, [scaleSnapValue]);
// Snap helper functions
const snapToGrid = useCallback((value: number): number => {
if (!snapEnabledRef.current || gridSnapRef.current <= 0) return value;
return Math.round(value / gridSnapRef.current) * gridSnapRef.current;
}, []);
const snapRotation = useCallback((value: number): number => {
if (!snapEnabledRef.current || rotationSnapRef.current <= 0) return value;
const degrees = (value * 180) / Math.PI;
const snappedDegrees = Math.round(degrees / rotationSnapRef.current) * rotationSnapRef.current;
return (snappedDegrees * Math.PI) / 180;
}, []);
const snapScale = useCallback((value: number): number => {
if (!snapEnabledRef.current || scaleSnapRef.current <= 0) return value;
return Math.round(value / scaleSnapRef.current) * scaleSnapRef.current;
}, []);
// Screen to world coordinate conversion - uses refs to avoid re-registering event handlers
const screenToWorld = useCallback((screenX: number, screenY: number) => {
const canvas = canvasRef.current;
@@ -205,8 +259,17 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
let rafId: number | null = null;
const resizeObserver = new ResizeObserver(() => {
resizeCanvas();
// 使用 requestAnimationFrame 避免 ResizeObserver loop 错误
// Use requestAnimationFrame to avoid ResizeObserver loop errors
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
resizeCanvas();
rafId = null;
});
});
if (containerRef.current) {
@@ -349,8 +412,38 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
isDraggingTransformRef.current = false;
canvas.style.cursor = 'grab';
// Apply snap on mouse up
const entity = selectedEntityRef.current;
if (entity && snapEnabledRef.current) {
const mode = transformModeRef.current;
const transform = entity.getComponent(TransformComponent);
if (transform) {
if (mode === 'move') {
transform.position.x = snapToGrid(transform.position.x);
transform.position.y = snapToGrid(transform.position.y);
} else if (mode === 'rotate') {
transform.rotation.z = snapRotation(transform.rotation.z);
} else if (mode === 'scale') {
transform.scale.x = snapScale(transform.scale.x);
transform.scale.y = snapScale(transform.scale.y);
}
}
const uiTransform = entity.getComponent(UITransformComponent);
if (uiTransform) {
if (mode === 'move') {
uiTransform.x = snapToGrid(uiTransform.x);
uiTransform.y = snapToGrid(uiTransform.y);
} else if (mode === 'rotate') {
uiTransform.rotation = snapRotation(uiTransform.rotation);
} else if (mode === 'scale') {
uiTransform.scaleX = snapScale(uiTransform.scaleX);
uiTransform.scaleY = snapScale(uiTransform.scaleY);
}
}
}
// Notify Inspector to refresh after transform change
// 通知 Inspector 在变换更改后刷新
if (messageHubRef.current && selectedEntityRef.current) {
messageHubRef.current.publish('entity:selected', {
entity: selectedEntityRef.current
@@ -383,6 +476,9 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
document.addEventListener('mouseup', handleMouseUp);
return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
window.removeEventListener('resize', resizeCanvas);
resizeObserver.disconnect();
canvas.removeEventListener('mousedown', handleMouseDown);
@@ -538,6 +634,16 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
setCamera2DZoom(1);
};
// Store handlers in refs to avoid dependency issues
const handlePlayRef = useRef(handlePlay);
const handlePauseRef = useRef(handlePause);
const handleStopRef = useRef(handleStop);
const handleRunInBrowserRef = useRef<(() => void) | null>(null);
const handleRunOnDeviceRef = useRef<(() => void) | null>(null);
handlePlayRef.current = handlePlay;
handlePauseRef.current = handlePause;
handleStopRef.current = handleStop;
const handleRunInBrowser = async () => {
setShowRunMenu(false);
@@ -749,6 +855,51 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
}
};
// Update refs after function definitions
handleRunInBrowserRef.current = handleRunInBrowser;
handleRunOnDeviceRef.current = handleRunOnDevice;
// Subscribe to MainToolbar events
useEffect(() => {
if (!messageHub) return;
const unsubscribeStart = messageHub.subscribe('preview:start', () => {
handlePlayRef.current();
messageHub.publish('preview:started', {});
});
const unsubscribePause = messageHub.subscribe('preview:pause', () => {
handlePauseRef.current();
messageHub.publish('preview:paused', {});
});
const unsubscribeStop = messageHub.subscribe('preview:stop', () => {
handleStopRef.current();
messageHub.publish('preview:stopped', {});
});
const unsubscribeStep = messageHub.subscribe('preview:step', () => {
engine.step();
});
const unsubscribeRunBrowser = messageHub.subscribe('viewport:run-in-browser', () => {
handleRunInBrowserRef.current?.();
});
const unsubscribeRunDevice = messageHub.subscribe('viewport:run-on-device', () => {
handleRunOnDeviceRef.current?.();
});
return () => {
unsubscribeStart();
unsubscribePause();
unsubscribeStop();
unsubscribeStep();
unsubscribeRunBrowser();
unsubscribeRunDevice();
};
}, [messageHub]);
const handleFullscreen = () => {
if (containerRef.current) {
if (document.fullscreenElement) {
@@ -788,11 +939,44 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
const gridSnapOptions = [1, 5, 10, 25, 50, 100];
const rotationSnapOptions = [5, 10, 15, 30, 45, 90];
const scaleSnapOptions = [0.1, 0.25, 0.5, 1];
const closeAllSnapMenus = useCallback(() => {
setShowGridSnapMenu(false);
setShowRotationSnapMenu(false);
setShowScaleSnapMenu(false);
setShowRunMenu(false);
}, []);
// Close menus when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (gridSnapMenuRef.current && !gridSnapMenuRef.current.contains(target)) {
setShowGridSnapMenu(false);
}
if (rotationSnapMenuRef.current && !rotationSnapMenuRef.current.contains(target)) {
setShowRotationSnapMenu(false);
}
if (scaleSnapMenuRef.current && !scaleSnapMenuRef.current.contains(target)) {
setShowScaleSnapMenu(false);
}
if (runMenuRef.current && !runMenuRef.current.contains(target)) {
setShowRunMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="viewport" ref={containerRef}>
<div className="viewport-toolbar">
<div className="viewport-toolbar-left">
{/* Transform tools group */}
{/* Internal Overlay Toolbar */}
<div className="viewport-internal-toolbar">
<div className="viewport-internal-toolbar-left">
{/* Transform tools */}
<div className="viewport-btn-group">
<button
className={`viewport-btn ${transformMode === 'select' ? 'active' : ''}`}
@@ -823,37 +1007,165 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
<Scaling size={14} />
</button>
</div>
<div className="viewport-divider" />
{/* View options group */}
<div className="viewport-btn-group">
{/* Snap toggle */}
<button
className={`viewport-btn ${snapEnabled ? 'active' : ''}`}
onClick={() => setSnapEnabled(!snapEnabled)}
title={locale === 'zh' ? '吸附开关' : 'Toggle Snap'}
>
<Magnet size={14} />
</button>
{/* Grid Snap Value */}
<div className="viewport-snap-dropdown" ref={gridSnapMenuRef}>
<button
className={`viewport-btn ${showGrid ? 'active' : ''}`}
onClick={() => setShowGrid(!showGrid)}
title={locale === 'zh' ? '显示网格' : 'Show Grid'}
className="viewport-snap-btn"
onClick={() => { closeAllSnapMenus(); setShowGridSnapMenu(!showGridSnapMenu); }}
title={locale === 'zh' ? '网格吸附' : 'Grid Snap'}
>
<Grid3x3 size={14} />
</button>
<button
className={`viewport-btn ${showGizmos ? 'active' : ''}`}
onClick={() => setShowGizmos(!showGizmos)}
title={locale === 'zh' ? '显示辅助工具' : 'Show Gizmos'}
>
{showGizmos ? <Eye size={14} /> : <EyeOff size={14} />}
<Grid3x3 size={12} />
<span>{gridSnapValue}</span>
<ChevronDown size={10} />
</button>
{showGridSnapMenu && (
<div className="viewport-snap-menu">
{gridSnapOptions.map((val) => (
<button
key={val}
className={gridSnapValue === val ? 'active' : ''}
onClick={() => { setGridSnapValue(val); setShowGridSnapMenu(false); }}
>
{val}
</button>
))}
</div>
)}
</div>
<div className="viewport-divider" />
{/* Run options dropdown */}
<div className="viewport-dropdown" ref={runMenuRef}>
{/* Rotation Snap Value */}
<div className="viewport-snap-dropdown" ref={rotationSnapMenuRef}>
<button
className="viewport-btn"
onClick={() => setShowRunMenu(!showRunMenu)}
className="viewport-snap-btn"
onClick={() => { closeAllSnapMenus(); setShowRotationSnapMenu(!showRotationSnapMenu); }}
title={locale === 'zh' ? '旋转吸附' : 'Rotation Snap'}
>
<RotateCw size={12} />
<span>{rotationSnapValue}°</span>
<ChevronDown size={10} />
</button>
{showRotationSnapMenu && (
<div className="viewport-snap-menu">
{rotationSnapOptions.map((val) => (
<button
key={val}
className={rotationSnapValue === val ? 'active' : ''}
onClick={() => { setRotationSnapValue(val); setShowRotationSnapMenu(false); }}
>
{val}°
</button>
))}
</div>
)}
</div>
{/* Scale Snap Value */}
<div className="viewport-snap-dropdown" ref={scaleSnapMenuRef}>
<button
className="viewport-snap-btn"
onClick={() => { closeAllSnapMenus(); setShowScaleSnapMenu(!showScaleSnapMenu); }}
title={locale === 'zh' ? '缩放吸附' : 'Scale Snap'}
>
<Scaling size={12} />
<span>{scaleSnapValue}</span>
<ChevronDown size={10} />
</button>
{showScaleSnapMenu && (
<div className="viewport-snap-menu">
{scaleSnapOptions.map((val) => (
<button
key={val}
className={scaleSnapValue === val ? 'active' : ''}
onClick={() => { setScaleSnapValue(val); setShowScaleSnapMenu(false); }}
>
{val}
</button>
))}
</div>
)}
</div>
</div>
<div className="viewport-internal-toolbar-right">
{/* View options */}
<button
className={`viewport-btn ${showGrid ? 'active' : ''}`}
onClick={() => setShowGrid(!showGrid)}
title={locale === 'zh' ? '显示网格' : 'Show Grid'}
>
<Grid3x3 size={14} />
</button>
<button
className={`viewport-btn ${showGizmos ? 'active' : ''}`}
onClick={() => setShowGizmos(!showGizmos)}
title={locale === 'zh' ? '显示辅助线' : 'Show Gizmos'}
>
{showGizmos ? <Eye size={14} /> : <EyeOff size={14} />}
</button>
<div className="viewport-divider" />
{/* Zoom display */}
<div className="viewport-zoom-display">
<ZoomIn size={12} />
<span>{Math.round(camera2DZoom * 100)}%</span>
</div>
<div className="viewport-divider" />
{/* Stats toggle */}
<button
className={`viewport-btn ${showStats ? 'active' : ''}`}
onClick={() => setShowStats(!showStats)}
title={locale === 'zh' ? '统计信息' : 'Stats'}
>
<Activity size={14} />
</button>
{/* Reset view */}
<button
className="viewport-btn"
onClick={handleReset}
title={locale === 'zh' ? '重置视图' : 'Reset View'}
>
<RotateCcw size={14} />
</button>
{/* Fullscreen */}
<button
className="viewport-btn"
onClick={handleFullscreen}
title={locale === 'zh' ? '全屏' : 'Fullscreen'}
>
<Maximize2 size={14} />
</button>
<div className="viewport-divider" />
{/* Run options */}
<div className="viewport-snap-dropdown" ref={runMenuRef}>
<button
className="viewport-snap-btn"
onClick={() => { closeAllSnapMenus(); setShowRunMenu(!showRunMenu); }}
title={locale === 'zh' ? '运行选项' : 'Run Options'}
>
<Globe size={14} />
<ChevronDown size={10} />
</button>
{showRunMenu && (
<div className="viewport-dropdown-menu">
<div className="viewport-snap-menu viewport-snap-menu-right">
<button onClick={handleRunInBrowser}>
<Globe size={14} />
{locale === 'zh' ? '浏览器运行' : 'Run in Browser'}
@@ -866,62 +1178,10 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
)}
</div>
</div>
{/* Centered playback controls */}
<div className="viewport-toolbar-center">
<div className="viewport-playback">
<button
className={`viewport-btn play-btn ${playState === 'playing' ? 'active' : ''}`}
onClick={handlePlay}
disabled={playState === 'playing'}
title={locale === 'zh' ? '播放' : 'Play'}
>
<Play size={16} />
</button>
<button
className={`viewport-btn pause-btn ${playState === 'paused' ? 'active' : ''}`}
onClick={handlePause}
disabled={playState !== 'playing'}
title={locale === 'zh' ? '暂停' : 'Pause'}
>
<Pause size={16} />
</button>
<button
className="viewport-btn stop-btn"
onClick={handleStop}
disabled={playState === 'stopped'}
title={locale === 'zh' ? '停止' : 'Stop'}
>
<Square size={16} />
</button>
</div>
</div>
<div className="viewport-toolbar-right">
<span className="viewport-zoom">{Math.round(camera2DZoom * 100)}%</span>
<div className="viewport-divider" />
<button
className="viewport-btn"
onClick={handleReset}
title={locale === 'zh' ? '重置视图' : 'Reset View'}
>
<RotateCcw size={14} />
</button>
<button
className={`viewport-btn ${showStats ? 'active' : ''}`}
onClick={() => setShowStats(!showStats)}
title={locale === 'zh' ? '显示统计信息' : 'Show Stats'}
>
<Activity size={14} />
</button>
<button
className="viewport-btn"
onClick={handleFullscreen}
title={locale === 'zh' ? '全屏' : 'Fullscreen'}
>
<Maximize2 size={14} />
</button>
</div>
</div>
<canvas ref={canvasRef} id="viewport-canvas" className="viewport-canvas" />
{showStats && (
<div className="viewport-stats">
<div className="viewport-stat">