Compare commits
12 Commits
feat/studi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 542949eaff | |||
|
|
b638842948 | ||
|
|
22026f2f1d | ||
| 89bdeff657 | |||
| b457a29a95 | |||
| 143e529d11 | |||
| 2e1d915922 | |||
|
|
a9a9668071 | ||
|
|
04c593ef5a | ||
| 4913da49ff | |||
|
|
e6cfcad2c0 | ||
| ade30363c8 |
@ -1,8 +1,10 @@
|
|||||||
/* === Hierarchy Panel === */
|
/* === Hierarchy Panel === */
|
||||||
|
/* Компактные строки (как Roblox Explorer): меньше вертикальных отступов —
|
||||||
|
больше объектов влезает без скролла. */
|
||||||
.hierarchy {
|
.hierarchy {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 6px 0;
|
padding: 4px 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -13,13 +15,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rootLine {
|
.rootLine {
|
||||||
padding: 6px 8px;
|
padding: 3px 8px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.systemItem {
|
.systemItem {
|
||||||
padding: 4px 8px 4px 28px;
|
padding: 2px 8px 2px 26px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@ -28,11 +30,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 6px 8px;
|
padding: 3px 8px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin-top: 6px;
|
margin-top: 3px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,8 +74,8 @@
|
|||||||
.item {
|
.item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 7px;
|
||||||
padding: 4px 8px;
|
padding: 2px 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|||||||
@ -73,6 +73,9 @@ const GLYPHS = {
|
|||||||
'arrow-down': () => (<><path d="M12 5v14" {...S}/><path d="M6 13l6 6 6-6" {...S}/></>),
|
'arrow-down': () => (<><path d="M12 5v14" {...S}/><path d="M6 13l6 6 6-6" {...S}/></>),
|
||||||
'arrow-left': () => (<><path d="M19 12H5" {...S}/><path d="M11 6l-6 6 6 6" {...S}/></>),
|
'arrow-left': () => (<><path d="M19 12H5" {...S}/><path d="M11 6l-6 6 6 6" {...S}/></>),
|
||||||
'arrow-right': () => (<><path d="M5 12h14" {...S}/><path d="M13 6l6 6-6 6" {...S}/></>),
|
'arrow-right': () => (<><path d="M5 12h14" {...S}/><path d="M13 6l6 6-6 6" {...S}/></>),
|
||||||
|
// Полноэкранный режим: 4 уголка наружу / внутрь.
|
||||||
|
'fullscreen': () => (<><path d="M4 9V4h5" {...S}/><path d="M20 9V4h-5" {...S}/><path d="M4 15v5h5" {...S}/><path d="M20 15v5h-5" {...S}/></>),
|
||||||
|
'fullscreen-exit': () => (<><path d="M9 4v5H4" {...S}/><path d="M15 4v5h5" {...S}/><path d="M9 20v-5H4" {...S}/><path d="M15 20v-5h5" {...S}/></>),
|
||||||
refresh: () => (<><path d="M20 11a8 8 0 0 0-14-4.5L4 9" {...S}/><path d="M4 4v5h5" {...S}/><path d="M4 13a8 8 0 0 0 14 4.5L20 15" {...S}/><path d="M20 20v-5h-5" {...S}/></>),
|
refresh: () => (<><path d="M20 11a8 8 0 0 0-14-4.5L4 9" {...S}/><path d="M4 4v5h5" {...S}/><path d="M4 13a8 8 0 0 0 14 4.5L20 15" {...S}/><path d="M20 20v-5h-5" {...S}/></>),
|
||||||
cycle: () => (<><path d="M20 11a8 8 0 0 0-14-4.5L4 9" {...S}/><path d="M4 4v5h5" {...S}/><path d="M4 13a8 8 0 0 0 14 4.5L20 15" {...S}/><path d="M20 20v-5h-5" {...S}/></>),
|
cycle: () => (<><path d="M20 11a8 8 0 0 0-14-4.5L4 9" {...S}/><path d="M4 4v5h5" {...S}/><path d="M4 13a8 8 0 0 0 14 4.5L20 15" {...S}/><path d="M20 20v-5h-5" {...S}/></>),
|
||||||
flag: () => (<><path d="M6 21V4" {...S}/><path d="M6 4h11l-2.5 4L17 12H6" {...S}/></>),
|
flag: () => (<><path d="M6 21V4" {...S}/><path d="M6 4h11l-2.5 4L17 12H6" {...S}/></>),
|
||||||
|
|||||||
@ -47,6 +47,11 @@ import cl from './KubikonEditor.module.css';
|
|||||||
import Icon from './Icon';
|
import Icon from './Icon';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
|
||||||
|
// В десктоп-приложении (Electron-обёртка, см. rublox-desktop) окно и так на
|
||||||
|
// весь экран без браузерной панели — fullscreen НЕ нужен. preload выставляет
|
||||||
|
// window.__RUBLOX_DESKTOP__. Глушим авто-fullscreen, чтобы не дёргать окно.
|
||||||
|
const IS_DESKTOP_APP = typeof window !== 'undefined' && !!window.__RUBLOX_DESKTOP__;
|
||||||
|
|
||||||
const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 секунд тишины → авто-сохранение
|
const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 секунд тишины → авто-сохранение
|
||||||
|
|
||||||
// Шаблон глобального скрипта.
|
// Шаблон глобального скрипта.
|
||||||
@ -470,6 +475,98 @@ const KubikonEditor = () => {
|
|||||||
|
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const sceneRef = useRef(null);
|
const sceneRef = useRef(null);
|
||||||
|
// === Fullscreen редактора ===
|
||||||
|
// Верхняя панель браузера съедает ~20% экрана. Браузер НЕ даёт включить
|
||||||
|
// fullscreen автоматически при загрузке (нужен user gesture), поэтому:
|
||||||
|
// 1) кнопка в шапке;
|
||||||
|
// 2) автоматический вход при ПЕРВОМ клике пользователя по редактору.
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const fsAutoTriedRef = useRef(false); // авто-вход пробуем только 1 раз
|
||||||
|
const requestEditorFullscreen = React.useCallback(() => {
|
||||||
|
try {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const req = root.requestFullscreen
|
||||||
|
|| root.webkitRequestFullscreen
|
||||||
|
|| root.mozRequestFullScreen
|
||||||
|
|| root.msRequestFullscreen;
|
||||||
|
if (req && !document.fullscreenElement) req.call(root).catch(() => {});
|
||||||
|
} catch (e) { /* юзер запретил — работаем в окне */ }
|
||||||
|
}, []);
|
||||||
|
const exitEditorFullscreen = React.useCallback(() => {
|
||||||
|
try {
|
||||||
|
const ex = document.exitFullscreen || document.webkitExitFullscreen
|
||||||
|
|| document.mozCancelFullScreen || document.msExitFullscreen;
|
||||||
|
if (ex && document.fullscreenElement) ex.call(document).catch?.(() => {});
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
const toggleEditorFullscreen = React.useCallback(() => {
|
||||||
|
if (document.fullscreenElement) exitEditorFullscreen();
|
||||||
|
else requestEditorFullscreen();
|
||||||
|
}, [requestEditorFullscreen, exitEditorFullscreen]);
|
||||||
|
// Следим за состоянием fullscreen (кнопка показывает актуальную иконку).
|
||||||
|
useEffect(() => {
|
||||||
|
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
|
document.addEventListener('webkitfullscreenchange', onFsChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('fullscreenchange', onFsChange);
|
||||||
|
document.removeEventListener('webkitfullscreenchange', onFsChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
// Автовход в fullscreen при ПЕРВОМ клике/нажатии по редактору. Один раз.
|
||||||
|
useEffect(() => {
|
||||||
|
// В десктоп-приложении окно и так на весь экран — авто-FS не нужен.
|
||||||
|
if (IS_DESKTOP_APP) return;
|
||||||
|
const tryAuto = () => {
|
||||||
|
if (fsAutoTriedRef.current) return;
|
||||||
|
fsAutoTriedRef.current = true;
|
||||||
|
if (!document.fullscreenElement) requestEditorFullscreen();
|
||||||
|
};
|
||||||
|
window.addEventListener('pointerdown', tryAuto, { once: true, capture: true });
|
||||||
|
window.addEventListener('keydown', tryAuto, { once: true, capture: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointerdown', tryAuto, { capture: true });
|
||||||
|
window.removeEventListener('keydown', tryAuto, { capture: true });
|
||||||
|
};
|
||||||
|
}, [requestEditorFullscreen]);
|
||||||
|
// При уходе со страницы редактора — выходим из fullscreen.
|
||||||
|
useEffect(() => () => { exitEditorFullscreen(); }, [exitEditorFullscreen]);
|
||||||
|
|
||||||
|
// === Регулируемая граница между «Объекты сцены» и «Свойства» ===
|
||||||
|
// Доля высоты под список объектов (0.2..0.85). Сохраняем в localStorage.
|
||||||
|
const [hierFraction, setHierFraction] = useState(() => {
|
||||||
|
const v = parseFloat(localStorage.getItem('rbxStudioHierFraction'));
|
||||||
|
return Number.isFinite(v) && v >= 0.2 && v <= 0.85 ? v : 0.5;
|
||||||
|
});
|
||||||
|
const rightPanelRef = useRef(null);
|
||||||
|
const splitDragRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const onMove = (e) => {
|
||||||
|
if (!splitDragRef.current || !rightPanelRef.current) return;
|
||||||
|
const rect = rightPanelRef.current.getBoundingClientRect();
|
||||||
|
// Доля от верха панели до курсора (за вычетом верхнего заголовка ~24px).
|
||||||
|
let f = (e.clientY - rect.top - 24) / Math.max(1, rect.height - 24);
|
||||||
|
f = Math.max(0.2, Math.min(0.85, f));
|
||||||
|
setHierFraction(f);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
if (!splitDragRef.current) return;
|
||||||
|
splitDragRef.current = false;
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
try { localStorage.setItem('rbxStudioHierFraction', String(hierFraction)); } catch (_) {}
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
window.addEventListener('pointerup', onUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
window.removeEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
|
}, [hierFraction]);
|
||||||
|
const startSplitDrag = React.useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
splitDragRef.current = true;
|
||||||
|
document.body.style.cursor = 'row-resize';
|
||||||
|
}, []);
|
||||||
// Team Create — клиент совместного редактирования + presence-overlay.
|
// Team Create — клиент совместного редактирования + presence-overlay.
|
||||||
const collabRef = useRef(null);
|
const collabRef = useRef(null);
|
||||||
const collabOverlayRef = useRef(null);
|
const collabOverlayRef = useRef(null);
|
||||||
@ -2146,7 +2243,9 @@ const KubikonEditor = () => {
|
|||||||
// fullscreen — иначе Ctrl+W/Ctrl+D случайно закрывают вкладку
|
// fullscreen — иначе Ctrl+W/Ctrl+D случайно закрывают вкладку
|
||||||
// в режиме игры. Это user gesture (клик по кнопке Play),
|
// в режиме игры. Это user gesture (клик по кнопке Play),
|
||||||
// поэтому requestFullscreen() разрешён.
|
// поэтому requestFullscreen() разрешён.
|
||||||
try {
|
// В десктоп-приложении этого риска нет (нет вкладок браузера) —
|
||||||
|
// окно и так на весь экран, FS не нужен.
|
||||||
|
if (!IS_DESKTOP_APP) try {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const req = root.requestFullscreen
|
const req = root.requestFullscreen
|
||||||
|| root.webkitRequestFullscreen
|
|| root.webkitRequestFullscreen
|
||||||
@ -2309,6 +2408,18 @@ const KubikonEditor = () => {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* Полноэкранный режим — съедает верхнюю панель браузера.
|
||||||
|
В десктоп-приложении не нужен (окно и так на весь экран). */}
|
||||||
|
{!IS_DESKTOP_APP && (
|
||||||
|
<button
|
||||||
|
onClick={toggleEditorFullscreen}
|
||||||
|
title={isFullscreen ? 'Выйти из полноэкранного режима (F11)' : 'На весь экран (F11)'}
|
||||||
|
className={cl.toolbarBtn}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 38, height: 38, padding: 0, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<Icon name={isFullscreen ? 'fullscreen-exit' : 'fullscreen'} size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */}
|
{/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */}
|
||||||
<button
|
<button
|
||||||
onClick={() => document.querySelector('[data-kubikon-bug-btn]')?.click()}
|
onClick={() => document.querySelector('[data-kubikon-bug-btn]')?.click()}
|
||||||
@ -3520,7 +3631,8 @@ const KubikonEditor = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Правая панель — Hierarchy + Inspector */}
|
{/* Правая панель — Hierarchy + Inspector */}
|
||||||
<aside className={cl.rightPanel}>
|
<aside className={cl.rightPanel} ref={rightPanelRef}>
|
||||||
|
<div className={cl.rightSection} style={{ flexGrow: hierFraction, flexBasis: 0 }}>
|
||||||
<div className={cl.panelTitle}>Объекты сцены</div>
|
<div className={cl.panelTitle}>Объекты сцены</div>
|
||||||
<HierarchyPanel
|
<HierarchyPanel
|
||||||
blocks={blocksList}
|
blocks={blocksList}
|
||||||
@ -3745,7 +3857,16 @@ const KubikonEditor = () => {
|
|||||||
onAssignToFolder={(kind, ref, folderId) =>
|
onAssignToFolder={(kind, ref, folderId) =>
|
||||||
sceneRef.current?.assignToFolder(kind, ref, folderId)}
|
sceneRef.current?.assignToFolder(kind, ref, folderId)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Перетаскиваемая граница между списком объектов и свойствами. */}
|
||||||
|
<div
|
||||||
|
className={cl.rightSplitter}
|
||||||
|
onPointerDown={startSplitDrag}
|
||||||
|
title="Потяните, чтобы изменить высоту"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={cl.rightSection} style={{ flexGrow: (1 - hierFraction), flexBasis: 0 }}>
|
||||||
<div className={cl.panelTitle}>Свойства</div>
|
<div className={cl.panelTitle}>Свойства</div>
|
||||||
<InspectorPanel
|
<InspectorPanel
|
||||||
selection={selection}
|
selection={selection}
|
||||||
@ -3882,6 +4003,7 @@ const KubikonEditor = () => {
|
|||||||
markDirty();
|
markDirty();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,8 @@
|
|||||||
font-family: "Roboto Condensed", system-ui, -apple-system, sans-serif;
|
font-family: "Roboto Condensed", system-ui, -apple-system, sans-serif;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 56px auto 1fr 28px;
|
/* UI уменьшен на ~10% (ближе к Roblox Studio): topbar 56→50, status 28→26. */
|
||||||
|
grid-template-rows: 50px auto 1fr 26px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@ -39,7 +40,7 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor *,
|
.editor *,
|
||||||
@ -52,8 +53,8 @@
|
|||||||
.topBar {
|
.topBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
padding: 0 16px;
|
padding: 0 14px;
|
||||||
background: linear-gradient(180deg, var(--bg-dark) 0%, var(--bg-darkest) 100%);
|
background: linear-gradient(180deg, var(--bg-dark) 0%, var(--bg-darkest) 100%);
|
||||||
border-bottom: 2px solid var(--border);
|
border-bottom: 2px solid var(--border);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@ -145,12 +146,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toolbarBtn {
|
.toolbarBtn {
|
||||||
padding: 8px 16px;
|
padding: 7px 13px;
|
||||||
background: var(--bg-mid);
|
background: var(--bg-mid);
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@ -210,7 +211,9 @@
|
|||||||
/* === WORKSPACE === */
|
/* === WORKSPACE === */
|
||||||
.workspace {
|
.workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 240px minmax(0, 1fr) 280px;
|
/* Правая панель шире (280→320) — длинные имена объектов не обрезаются,
|
||||||
|
больше места под список и свойства. Левая чуть уже (240→224). */
|
||||||
|
grid-template-columns: 224px minmax(0, 1fr) 320px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
@ -233,18 +236,61 @@
|
|||||||
|
|
||||||
.rightPanel {
|
.rightPanel {
|
||||||
border-left: 2px solid var(--border);
|
border-left: 2px solid var(--border);
|
||||||
|
overflow: hidden; /* секции скроллятся внутри себя, панель — нет */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Секция правой панели (список объектов / свойства) — занимает свою долю
|
||||||
|
высоты, скроллится независимо. Доля регулируется сплиттером. */
|
||||||
|
.rightSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Перетаскиваемая граница между списком объектов и свойствами. */
|
||||||
|
.rightSplitter {
|
||||||
|
height: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: row-resize;
|
||||||
|
background: var(--bg-mid);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.rightSplitter::before {
|
||||||
|
/* визуальная «ручка» — три точки по центру */
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 28px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--text-dim);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.rightSplitter:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.rightSplitter:hover::before {
|
||||||
|
background: #fff;
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelTitle {
|
.panelTitle {
|
||||||
padding: 12px 16px;
|
padding: 7px 14px;
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1.5px;
|
letter-spacing: 1.3px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
background: var(--bg-mid);
|
background: var(--bg-mid);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelTitle:first-child {
|
.panelTitle:first-child {
|
||||||
|
|||||||
@ -35,6 +35,8 @@ import {
|
|||||||
ParticleSystem,
|
ParticleSystem,
|
||||||
Texture,
|
Texture,
|
||||||
Ray,
|
Ray,
|
||||||
|
Matrix,
|
||||||
|
HighlightLayer,
|
||||||
PointerEventTypes,
|
PointerEventTypes,
|
||||||
Tools as BabylonTools,
|
Tools as BabylonTools,
|
||||||
ColorCurves,
|
ColorCurves,
|
||||||
@ -198,6 +200,18 @@ export class BabylonScene {
|
|||||||
this._freeDragCandidate = null; // {mesh} — потенциальный объект для перетаскивания
|
this._freeDragCandidate = null; // {mesh} — потенциальный объект для перетаскивания
|
||||||
this._freeDragActive = false; // идёт ли перетаскивание
|
this._freeDragActive = false; // идёт ли перетаскивание
|
||||||
this._freeDragHalf = null; // {x,y,z} полу-габариты объекта (для коллизий)
|
this._freeDragHalf = null; // {x,y,z} полу-габариты объекта (для коллизий)
|
||||||
|
// Рамка выделения (rubber-band / marquee): ЛКМ-drag по пустому месту.
|
||||||
|
this._marqueeCandidate = null; // {startX,startY,curX,curY,additive}
|
||||||
|
this._marqueeActive = false; // появилась ли рамка (после сдвига)
|
||||||
|
this._marqueeEl = null; // DOM-оверлей прямоугольника
|
||||||
|
// Групповой пивот multi-выделения (для гизмо).
|
||||||
|
this._multiPivot = null;
|
||||||
|
this._multiPivotLast = null;
|
||||||
|
// Hover-подсветка (белый контур при наведении, как в Roblox Studio).
|
||||||
|
this._hoverLayer = null; // HighlightLayer
|
||||||
|
this._hoverMeshes = []; // подсвеченные сейчас меши
|
||||||
|
this._hoverKey = null; // ключ текущего hover-объекта (для throttle)
|
||||||
|
this._hoverRaf = 0; // requestAnimationFrame id (throttle pick)
|
||||||
this._isDragPlacing = false; // флаг drag-постановки/удаления блоков
|
this._isDragPlacing = false; // флаг drag-постановки/удаления блоков
|
||||||
this._isTerrainBrushing = false; // флаг drag-кисти террейна
|
this._isTerrainBrushing = false; // флаг drag-кисти террейна
|
||||||
this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять
|
this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять
|
||||||
@ -1375,6 +1389,18 @@ export class BabylonScene {
|
|||||||
// когда родительская scene control включён (мы убрали detachControl).
|
// когда родительская scene control включён (мы убрали detachControl).
|
||||||
this._gizmoLayer = new UtilityLayerRenderer(this.scene);
|
this._gizmoLayer = new UtilityLayerRenderer(this.scene);
|
||||||
|
|
||||||
|
// Hover-подсветка: белый контур по краю объекта при наведении мышью
|
||||||
|
// (как в Roblox Studio). HighlightLayer рисует мягкий outline.
|
||||||
|
try {
|
||||||
|
this._hoverLayer = new HighlightLayer('hoverLayer', this.scene, {
|
||||||
|
blurHorizontalSize: 1.0,
|
||||||
|
blurVerticalSize: 1.0,
|
||||||
|
});
|
||||||
|
// Тонкая, не «неоновая» обводка — ближе к Roblox.
|
||||||
|
this._hoverLayer.innerGlow = false;
|
||||||
|
this._hoverLayer.outerGlow = true;
|
||||||
|
} catch (e) { console.warn('[hover] HighlightLayer init failed', e); }
|
||||||
|
|
||||||
this._gizmo = new GizmoController(this._gizmoLayer, this.scene);
|
this._gizmo = new GizmoController(this._gizmoLayer, this.scene);
|
||||||
this._gizmo.setMode('select'); // по умолчанию — без манипулятора
|
this._gizmo.setMode('select'); // по умолчанию — без манипулятора
|
||||||
this._gizmo.setSnap(1.0); // снэп для блоков
|
this._gizmo.setSnap(1.0); // снэп для блоков
|
||||||
@ -1388,6 +1414,8 @@ export class BabylonScene {
|
|||||||
// Групповая папка — применяем дельту в реальном времени (видно движение).
|
// Групповая папка — применяем дельту в реальном времени (видно движение).
|
||||||
const sel = this.selection?.getSelection?.();
|
const sel = this.selection?.getSelection?.();
|
||||||
if (sel && sel.type === 'folder') this._onFolderGizmoDrag(mode);
|
if (sel && sel.type === 'folder') this._onFolderGizmoDrag(mode);
|
||||||
|
// Multi-выделение (рамка) — двигаем всю группу по дельте пивота.
|
||||||
|
if (sel && sel.type === 'multi') this._onMultiGizmoDrag(mode);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Привязка гизмо к выделенному
|
// Привязка гизмо к выделенному
|
||||||
@ -2450,9 +2478,15 @@ export class BabylonScene {
|
|||||||
// Free-drag: ЛКМ на объекте при tool=select (и без gizmo-перетаскивания).
|
// Free-drag: ЛКМ на объекте при tool=select (и без gizmo-перетаскивания).
|
||||||
// Запоминаем объект как кандидата — реальное перетаскивание начнётся
|
// Запоминаем объект как кандидата — реальное перетаскивание начнётся
|
||||||
// в mousemove, если курсор сдвинется (иначе это просто клик-выбор).
|
// в mousemove, если курсор сдвинется (иначе это просто клик-выбор).
|
||||||
|
// Если ЛКМ попала в ПУСТОЕ место (не объект) — запускаем рамку
|
||||||
|
// выделения (rubber-band / marquee).
|
||||||
if (e.button === 0 && !e.shiftKey && this._activeTool === 'select' && !this._isPlaying) {
|
if (e.button === 0 && !e.shiftKey && this._activeTool === 'select' && !this._isPlaying) {
|
||||||
if (this._beginFreeDragCandidate()) {
|
if (this._beginFreeDragCandidate()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
} else {
|
||||||
|
// Пусто под курсором → кандидат на рамку выделения.
|
||||||
|
// Реальная рамка появится в mousemove после сдвига.
|
||||||
|
this._beginMarqueeCandidate(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2487,6 +2521,7 @@ export class BabylonScene {
|
|||||||
|
|
||||||
if (e.button === 2) {
|
if (e.button === 2) {
|
||||||
this._isRotating = true;
|
this._isRotating = true;
|
||||||
|
this._clearHover(); // прячем hover пока крутим камеру
|
||||||
this._lastMouseX = e.clientX;
|
this._lastMouseX = e.clientX;
|
||||||
this._lastMouseY = e.clientY;
|
this._lastMouseY = e.clientY;
|
||||||
canvas.style.cursor = 'grabbing';
|
canvas.style.cursor = 'grabbing';
|
||||||
@ -2521,6 +2556,24 @@ export class BabylonScene {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Рамка выделения (marquee): тянем прямоугольник. Активируем после
|
||||||
|
// небольшого сдвига, чтобы обычный клик по пустому месту (= снять
|
||||||
|
// выделение) не превращался в рамку.
|
||||||
|
if (this._marqueeCandidate) {
|
||||||
|
if (!this._marqueeActive) {
|
||||||
|
const ddx = Math.abs(e.clientX - this._marqueeCandidate.startClientX);
|
||||||
|
const ddy = Math.abs(e.clientY - this._marqueeCandidate.startClientY);
|
||||||
|
if (ddx > 4 || ddy > 4) {
|
||||||
|
this._marqueeActive = true;
|
||||||
|
this._showMarqueeBox();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this._marqueeActive) {
|
||||||
|
this._updateMarqueeBox(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Free-drag: тянем объект ЛКМ. Активируем после небольшого сдвига,
|
// Free-drag: тянем объект ЛКМ. Активируем после небольшого сдвига,
|
||||||
// чтобы обычный клик-выбор не превращался в перетаскивание.
|
// чтобы обычный клик-выбор не превращался в перетаскивание.
|
||||||
if (this._freeDragCandidate) {
|
if (this._freeDragCandidate) {
|
||||||
@ -2558,6 +2611,13 @@ export class BabylonScene {
|
|||||||
this._updateTerrainBrushPosition();
|
this._updateTerrainBrushPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hover-подсветка (белый контур при наведении). Только инструмент
|
||||||
|
// «Выделение», не в play, не во время вращения/панорамы камеры.
|
||||||
|
if (!this._isPlaying && this._activeTool === 'select'
|
||||||
|
&& !this._isRotating && !this._isPanning && !this._marqueeActive) {
|
||||||
|
this._scheduleHoverUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
if (!this._isRotating && !this._isPanning) return;
|
if (!this._isRotating && !this._isPanning) return;
|
||||||
const dx = e.clientX - this._lastMouseX;
|
const dx = e.clientX - this._lastMouseX;
|
||||||
const dy = e.clientY - this._lastMouseY;
|
const dy = e.clientY - this._lastMouseY;
|
||||||
@ -2579,6 +2639,18 @@ export class BabylonScene {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onMouseUp = (e) => {
|
const onMouseUp = (e) => {
|
||||||
|
// Рамка выделения: завершаем. Если рамку реально тянули — отбираем
|
||||||
|
// объекты внутри и НЕ обрабатываем как клик (иначе сбросит выбор).
|
||||||
|
if (this._marqueeCandidate) {
|
||||||
|
const wasActive = this._marqueeActive;
|
||||||
|
this._endMarquee(e);
|
||||||
|
if (wasActive) {
|
||||||
|
this._mouseDownButton = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Не тянули (просто клик по пустому) — продолжаем обычную
|
||||||
|
// обработку клика ниже (она снимет выделение).
|
||||||
|
}
|
||||||
// Free-drag: завершаем перетаскивание. Если объект реально тащили —
|
// Free-drag: завершаем перетаскивание. Если объект реально тащили —
|
||||||
// фиксируем историю и НЕ обрабатываем как клик (иначе сбросит выбор).
|
// фиксируем историю и НЕ обрабатываем как клик (иначе сбросит выбор).
|
||||||
if (this._freeDragCandidate) {
|
if (this._freeDragCandidate) {
|
||||||
@ -2831,12 +2903,15 @@ export class BabylonScene {
|
|||||||
canvas.addEventListener('mousedown', onMouseDown, true);
|
canvas.addEventListener('mousedown', onMouseDown, true);
|
||||||
canvas.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
canvas.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||||
canvas.addEventListener('contextmenu', onContextMenu, true);
|
canvas.addEventListener('contextmenu', onContextMenu, true);
|
||||||
|
// Курсор ушёл с canvas → снять hover-подсветку.
|
||||||
|
const onCanvasLeave = () => this._clearHover();
|
||||||
// mousemove/mouseup на window — для drag за пределами canvas.
|
// mousemove/mouseup на window — для drag за пределами canvas.
|
||||||
window.addEventListener('mousemove', onMouseMove);
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
window.addEventListener('mouseup', onMouseUp);
|
window.addEventListener('mouseup', onMouseUp);
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
window.addEventListener('keyup', onKeyUp);
|
window.addEventListener('keyup', onKeyUp);
|
||||||
window.addEventListener('blur', onBlur);
|
window.addEventListener('blur', onBlur);
|
||||||
|
canvas.addEventListener('mouseleave', onCanvasLeave);
|
||||||
|
|
||||||
this._listeners = [
|
this._listeners = [
|
||||||
{ target: canvas, type: 'mousedown', fn: onMouseDown, opts: true },
|
{ target: canvas, type: 'mousedown', fn: onMouseDown, opts: true },
|
||||||
@ -2847,6 +2922,7 @@ export class BabylonScene {
|
|||||||
{ target: window, type: 'keydown', fn: onKeyDown },
|
{ target: window, type: 'keydown', fn: onKeyDown },
|
||||||
{ target: window, type: 'keyup', fn: onKeyUp },
|
{ target: window, type: 'keyup', fn: onKeyUp },
|
||||||
{ target: window, type: 'blur', fn: onBlur },
|
{ target: window, type: 'blur', fn: onBlur },
|
||||||
|
{ target: canvas, type: 'mouseleave', fn: onCanvasLeave },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4047,6 +4123,58 @@ export class BabylonScene {
|
|||||||
if (this._onSceneChange) this._onSceneChange();
|
if (this._onSceneChange) this._onSceneChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Групповой гизмо для multi-выделения (рамка) ─────────────────────────
|
||||||
|
// По аналогии с папкой: пивот в центре группы, drag двигает/вращает/
|
||||||
|
// масштабирует пивот, дельта применяется ко всем объектам через
|
||||||
|
// selection.moveMultiBy. Сейчас поддержан move (перемещение группы) —
|
||||||
|
// самая нужная операция; rotate/scale для произвольного multi сложнее
|
||||||
|
// (блоки на сетке) и пока сводятся к move.
|
||||||
|
|
||||||
|
/** Создать пивот-узел в центре multi-группы и привязать к нему gizmo. */
|
||||||
|
_attachMultiGizmo(center) {
|
||||||
|
try {
|
||||||
|
if (this._multiPivot) { this._multiPivot.dispose(); this._multiPivot = null; }
|
||||||
|
const pivot = new TransformNode('multiPivot', this.scene);
|
||||||
|
pivot.position = new Vector3(center.x, center.y, center.z);
|
||||||
|
pivot.rotation = new Vector3(0, 0, 0);
|
||||||
|
pivot.scaling = new Vector3(1, 1, 1);
|
||||||
|
this._multiPivot = pivot;
|
||||||
|
this._multiPivotLast = { x: center.x, y: center.y, z: center.z };
|
||||||
|
if (this._gizmo) {
|
||||||
|
this._gizmo.attachTo(pivot);
|
||||||
|
this._gizmo.refreshMode();
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('[multiGizmo] attach failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Инкрементально применить движение пивота к объектам группы во время drag. */
|
||||||
|
_onMultiGizmoDrag(mode) {
|
||||||
|
const pivot = this._multiPivot;
|
||||||
|
const last = this._multiPivotLast;
|
||||||
|
if (!pivot || !last || !this.selection) return;
|
||||||
|
if (mode === 'move') {
|
||||||
|
const dx = pivot.position.x - last.x;
|
||||||
|
const dy = pivot.position.y - last.y;
|
||||||
|
const dz = pivot.position.z - last.z;
|
||||||
|
// Блоки двигаются по сетке (целые клетки) — копим дробный остаток,
|
||||||
|
// чтобы при медленном drag блоки тоже сдвигались на целые числа.
|
||||||
|
if (dx || dy || dz) {
|
||||||
|
this.selection.moveMultiBy(dx, dy, dz);
|
||||||
|
last.x = pivot.position.x; last.y = pivot.position.y; last.z = pivot.position.z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// rotate/scale для произвольного multi не применяем (см. комментарий выше).
|
||||||
|
}
|
||||||
|
|
||||||
|
/** dragEnd: добираем остаток дельты и пересоздаём пивот в новом центре. */
|
||||||
|
_applyMultiGizmo(mode) {
|
||||||
|
if (!this.selection) return;
|
||||||
|
this._onMultiGizmoDrag(mode);
|
||||||
|
const c = this.selection.getMultiCenter();
|
||||||
|
if (c) this._attachMultiGizmo(c);
|
||||||
|
if (this._onSceneChange) this._onSceneChange();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обновить гизмо под текущее выделение.
|
* Обновить гизмо под текущее выделение.
|
||||||
*/
|
*/
|
||||||
@ -4057,6 +4185,11 @@ export class BabylonScene {
|
|||||||
try { this._folderPivot.dispose(); } catch (e) {}
|
try { this._folderPivot.dispose(); } catch (e) {}
|
||||||
this._folderPivot = null; this._folderPivotId = null;
|
this._folderPivot = null; this._folderPivotId = null;
|
||||||
}
|
}
|
||||||
|
// Сменилось выделение и это НЕ multi → убрать пивот multi.
|
||||||
|
if ((!sel || sel.type !== 'multi') && this._multiPivot) {
|
||||||
|
try { this._multiPivot.dispose(); } catch (e) {}
|
||||||
|
this._multiPivot = null; this._multiPivotLast = null;
|
||||||
|
}
|
||||||
if (!sel) {
|
if (!sel) {
|
||||||
this._gizmo.attachTo(null);
|
this._gizmo.attachTo(null);
|
||||||
return;
|
return;
|
||||||
@ -4071,6 +4204,14 @@ export class BabylonScene {
|
|||||||
} else if (sel.type === 'folder') {
|
} else if (sel.type === 'folder') {
|
||||||
// Групповой gizmo — привязан к пивоту папки (создан в _attachFolderGizmo).
|
// Групповой gizmo — привязан к пивоту папки (создан в _attachFolderGizmo).
|
||||||
if (this._folderPivot) this._gizmo.attachTo(this._folderPivot);
|
if (this._folderPivot) this._gizmo.attachTo(this._folderPivot);
|
||||||
|
} else if (sel.type === 'multi') {
|
||||||
|
// Multi (рамка) — привязан к пивоту группы. Если пивота ещё нет
|
||||||
|
// (multi выставлен не из рамки, напр. Ctrl+клик) — создаём его.
|
||||||
|
if (!this._multiPivot) {
|
||||||
|
const c = this.selection.getMultiCenter?.();
|
||||||
|
if (c) { this._attachMultiGizmo(c); return; }
|
||||||
|
}
|
||||||
|
if (this._multiPivot) this._gizmo.attachTo(this._multiPivot);
|
||||||
}
|
}
|
||||||
// Переустанавливаем режим чтобы sub-gizmo (move/rotate/scale)
|
// Переустанавливаем режим чтобы sub-gizmo (move/rotate/scale)
|
||||||
// гарантированно пересоздалась поверх нового attached-mesh.
|
// гарантированно пересоздалась поверх нового attached-mesh.
|
||||||
@ -4112,6 +4253,10 @@ export class BabylonScene {
|
|||||||
this._applyFolderGizmo(mode);
|
this._applyFolderGizmo(mode);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (sel.type === 'multi') {
|
||||||
|
this._applyMultiGizmo(mode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (sel.type === 'block') {
|
if (sel.type === 'block') {
|
||||||
if (mode === 'move') {
|
if (mode === 'move') {
|
||||||
// Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ)
|
// Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ)
|
||||||
@ -5543,9 +5688,90 @@ export class BabylonScene {
|
|||||||
* Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y).
|
* Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y).
|
||||||
* Модель: создаёт копию со смещением +1 по X.
|
* Модель: создаёт копию со смещением +1 по X.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Дублировать всё multi-выделение (Ctrl+D над рамкой). Модели/примитивы/
|
||||||
|
* user-модели копируются РОВНО на месте оригиналов (как в Roblox Studio,
|
||||||
|
* дубль сразу можно тащить). Блоки — в свободную клетку рядом (нельзя
|
||||||
|
* наложить два блока в одну клетку). По завершении дубли становятся новым
|
||||||
|
* multi-выделением.
|
||||||
|
*/
|
||||||
|
async _duplicateMulti() {
|
||||||
|
const items = this.selection?.getMultiSelection?.() || [];
|
||||||
|
if (!items.length) return;
|
||||||
|
const newSel = [];
|
||||||
|
for (const it of items) {
|
||||||
|
try {
|
||||||
|
if (it.kind === 'block') {
|
||||||
|
const { x, y, z } = it.ref;
|
||||||
|
const typeId = this.blockManager?.blocks.get(`${x},${y},${z}`)?.metadata?.blockTypeId;
|
||||||
|
if (typeId == null) continue;
|
||||||
|
const cands = [[1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1], [0, 1, 0]];
|
||||||
|
for (const [dx, dy, dz] of cands) {
|
||||||
|
const nx = x + dx, ny = y + dy, nz = z + dz;
|
||||||
|
if (ny < 0) continue;
|
||||||
|
if (!this.blockManager.hasBlock(nx, ny, nz)) {
|
||||||
|
this.blockManager.addBlock(nx, ny, nz, typeId);
|
||||||
|
this._copyScriptsToNewObject('block', { x, y, z }, { x: nx, y: ny, z: nz });
|
||||||
|
newSel.push({ kind: 'block', ref: { x: nx, y: ny, z: nz } });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (it.kind === 'primitive') {
|
||||||
|
const d = this.primitiveManager?.instances.get(it.ref);
|
||||||
|
if (!d) continue;
|
||||||
|
const newId = this.primitiveManager.addInstance(d.type, {
|
||||||
|
x: d.x, y: d.y, z: d.z, sx: d.sx, sy: d.sy, sz: d.sz,
|
||||||
|
rotationX: d.rotationX || 0, rotationY: d.rotationY || 0, rotationZ: d.rotationZ || 0,
|
||||||
|
color: d.color, material: d.material,
|
||||||
|
canCollide: d.canCollide, visible: d.visible, anchored: d.anchored,
|
||||||
|
textureAsset: d.textureAsset || null,
|
||||||
|
brightness: d.brightness, range: d.range, effect: d.effect,
|
||||||
|
});
|
||||||
|
if (newId != null) {
|
||||||
|
this._copyScriptsToNewObject('primitive', it.ref, newId);
|
||||||
|
newSel.push({ kind: 'primitive', ref: newId });
|
||||||
|
}
|
||||||
|
} else if (it.kind === 'model') {
|
||||||
|
const d = this.modelManager?.instances.get(it.ref);
|
||||||
|
if (!d) continue;
|
||||||
|
const newId = await this.modelManager.addInstance(d.modelTypeId, d.x, d.y, d.z, d.rotationY || 0);
|
||||||
|
if (newId != null) {
|
||||||
|
this._copyScriptsToNewObject('model', it.ref, newId);
|
||||||
|
newSel.push({ kind: 'model', ref: newId });
|
||||||
|
}
|
||||||
|
} else if (it.kind === 'userModel') {
|
||||||
|
const d = this.userModelManager?.instances.get(it.ref);
|
||||||
|
if (!d) continue;
|
||||||
|
const newId = await this.userModelManager.addInstance(
|
||||||
|
d.userModelTypeId, d.x, d.y, d.z, d.rotationY || 0,
|
||||||
|
{ currentUserId: this._currentUserId || null });
|
||||||
|
if (newId != null) {
|
||||||
|
this._copyScriptsToNewObject('userModel', it.ref, newId);
|
||||||
|
newSel.push({ kind: 'userModel', ref: newId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[BabylonScene] duplicate multi item error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Выделяем дубли как новую группу.
|
||||||
|
if (newSel.length && this.selection) {
|
||||||
|
this.selection.setMultiSelection(newSel, false);
|
||||||
|
const c = this.selection.getMultiCenter?.();
|
||||||
|
if (c) this._attachMultiGizmo(c);
|
||||||
|
}
|
||||||
|
this.history?.markChange();
|
||||||
|
if (this._onSceneChange) this._onSceneChange();
|
||||||
|
}
|
||||||
|
|
||||||
duplicateSelected() {
|
duplicateSelected() {
|
||||||
const sel = this.selection?.getSelection();
|
const sel = this.selection?.getSelection();
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
|
if (sel.type === 'multi') {
|
||||||
|
this._duplicateMulti();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (sel.type === 'block') {
|
if (sel.type === 'block') {
|
||||||
// Ищем свободную клетку рядом
|
// Ищем свободную клетку рядом
|
||||||
const candidates = [
|
const candidates = [
|
||||||
@ -5901,6 +6127,18 @@ export class BabylonScene {
|
|||||||
const m = pick.mesh;
|
const m = pick.mesh;
|
||||||
if (m.name && (m.name.startsWith('gridLine') || m.name === 'ground')) return false;
|
if (m.name && (m.name.startsWith('gridLine') || m.name === 'ground')) return false;
|
||||||
if (m.metadata?._isBlockProto) return false; // блоки тащим только гизмо
|
if (m.metadata?._isBlockProto) return false; // блоки тащим только гизмо
|
||||||
|
|
||||||
|
// Если есть multi-выделение и кликнули по объекту ВНУТРИ него —
|
||||||
|
// тащим всю группу (а не пере-выбираем один объект).
|
||||||
|
const curSel = this.selection?.getSelection?.();
|
||||||
|
if (curSel?.type === 'multi' && this._meshInMultiSelection(m)) {
|
||||||
|
const c = this.selection.getMultiCenter();
|
||||||
|
this._freeDragCandidate = { multi: true, last: { ...(c || { x: 0, y: 0, z: 0 }) } };
|
||||||
|
this._freeDragHalf = { x: 0.5, y: 0.5, z: 0.5 };
|
||||||
|
this._freeDragActive = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Выбираем объект (резолв mesh→тип внутри selection).
|
// Выбираем объект (резолв mesh→тип внутри selection).
|
||||||
this.selection?.selectByMesh(m);
|
this.selection?.selectByMesh(m);
|
||||||
const sel = this.selection?.getSelection();
|
const sel = this.selection?.getSelection();
|
||||||
@ -5945,6 +6183,23 @@ export class BabylonScene {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Multi (рамка): тащим всю группу по дельте центра в горизонтальной
|
||||||
|
// плоскости (как папку). Гизмо-пивот пересоздадим в _endFreeDrag.
|
||||||
|
if (cand.multi) {
|
||||||
|
const ray = this.scene.createPickingRay(this.scene.pointerX, this.scene.pointerY, null, this.scene.activeCamera);
|
||||||
|
if (Math.abs(ray.direction.y) < 1e-4) return;
|
||||||
|
const t = (cand.last.y - ray.origin.y) / ray.direction.y;
|
||||||
|
if (t < 0) return;
|
||||||
|
const px = ray.origin.x + ray.direction.x * t;
|
||||||
|
const pz = ray.origin.z + ray.direction.z * t;
|
||||||
|
const dx = px - cand.last.x, dz = pz - cand.last.z;
|
||||||
|
if (dx || dz) {
|
||||||
|
this.selection?.moveMultiBy(dx, 0, dz);
|
||||||
|
cand.last.x = px; cand.last.z = pz;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const root = cand.root;
|
const root = cand.root;
|
||||||
const half = this._freeDragHalf || { x: 0.5, y: 0.5, z: 0.5 };
|
const half = this._freeDragHalf || { x: 0.5, y: 0.5, z: 0.5 };
|
||||||
|
|
||||||
@ -5999,16 +6254,306 @@ export class BabylonScene {
|
|||||||
/** Завершить free-drag, зафиксировать изменение в истории. */
|
/** Завершить free-drag, зафиксировать изменение в истории. */
|
||||||
_endFreeDrag() {
|
_endFreeDrag() {
|
||||||
const wasActive = this._freeDragActive;
|
const wasActive = this._freeDragActive;
|
||||||
|
const wasMulti = this._freeDragCandidate?.multi;
|
||||||
this._freeDragCandidate = null;
|
this._freeDragCandidate = null;
|
||||||
this._freeDragActive = false;
|
this._freeDragActive = false;
|
||||||
this._freeDragHalf = null;
|
this._freeDragHalf = null;
|
||||||
if (wasActive) {
|
if (wasActive) {
|
||||||
|
// После перетаскивания multi-группы — пересоздать пивот гизмо в новом центре.
|
||||||
|
if (wasMulti && this.selection) {
|
||||||
|
const c = this.selection.getMultiCenter();
|
||||||
|
if (c) this._attachMultiGizmo(c);
|
||||||
|
}
|
||||||
this.history?.markChange();
|
this.history?.markChange();
|
||||||
if (this._onSceneChange) this._onSceneChange();
|
if (this._onSceneChange) this._onSceneChange();
|
||||||
}
|
}
|
||||||
return wasActive;
|
return wasActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Проверить, принадлежит ли mesh одному из объектов в multi-выделении. */
|
||||||
|
_meshInMultiSelection(mesh) {
|
||||||
|
if (!this.selection) return false;
|
||||||
|
const multi = this.selection.getMultiSelection?.() || [];
|
||||||
|
if (!multi.length) return false;
|
||||||
|
const md = mesh.metadata || {};
|
||||||
|
let kind = null, ref = null;
|
||||||
|
if (md.isBlock) { kind = 'block'; ref = { x: md.gridX, y: md.gridY, z: md.gridZ }; }
|
||||||
|
else if (md.isModel) { kind = 'model'; ref = md.instanceId; }
|
||||||
|
else if (md.isPrimitive) { kind = 'primitive'; ref = md.primitiveId; }
|
||||||
|
else if (md.isUserModel) { kind = 'userModel'; ref = md.instanceId; }
|
||||||
|
else return false;
|
||||||
|
return multi.some(it => {
|
||||||
|
if (it.kind !== kind) return false;
|
||||||
|
if (kind === 'block') return it.ref.x === ref.x && it.ref.y === ref.y && it.ref.z === ref.z;
|
||||||
|
return it.ref === ref;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Рамка выделения (rubber-band / marquee) ─────────────────────────────
|
||||||
|
// ЛКМ зажата на ПУСТОМ месте (не на объекте) при tool=select → тянем
|
||||||
|
// прямоугольник. Все объекты, чей ЦЕНТР (экранная проекция позиции)
|
||||||
|
// попадает в прямоугольник — выделяются (multi-select). Пол не выделяется.
|
||||||
|
|
||||||
|
/** Запомнить старт рамки. Реальная рамка появится после сдвига курсора. */
|
||||||
|
_beginMarqueeCandidate(e) {
|
||||||
|
this._clearHover(); // не держим белый контур во время рамки
|
||||||
|
const r = this.canvas.getBoundingClientRect();
|
||||||
|
this._marqueeCandidate = {
|
||||||
|
startClientX: e.clientX,
|
||||||
|
startClientY: e.clientY,
|
||||||
|
// Координаты относительно canvas (для проекции и оверлея).
|
||||||
|
startX: e.clientX - r.left,
|
||||||
|
startY: e.clientY - r.top,
|
||||||
|
curX: e.clientX - r.left,
|
||||||
|
curY: e.clientY - r.top,
|
||||||
|
additive: e.ctrlKey || e.metaKey, // Ctrl — добавить к текущему выделению
|
||||||
|
};
|
||||||
|
this._marqueeActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Создать DOM-оверлей прямоугольника поверх канваса. */
|
||||||
|
_showMarqueeBox() {
|
||||||
|
if (!this._marqueeEl) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.cssText = [
|
||||||
|
'position:absolute', 'pointer-events:none', 'z-index:50',
|
||||||
|
'border:1px solid #38d957',
|
||||||
|
'background:rgba(56,217,87,0.15)',
|
||||||
|
'box-shadow:0 0 0 1px rgba(0,0,0,0.25) inset',
|
||||||
|
'left:0', 'top:0', 'width:0', 'height:0',
|
||||||
|
].join(';');
|
||||||
|
// Вставляем в родителя канваса (он position:relative в редакторе).
|
||||||
|
const parent = this.canvas.parentElement || document.body;
|
||||||
|
parent.appendChild(el);
|
||||||
|
this._marqueeEl = el;
|
||||||
|
}
|
||||||
|
this._marqueeEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Обновить размеры оверлея под текущее положение курсора. */
|
||||||
|
_updateMarqueeBox(e) {
|
||||||
|
const cand = this._marqueeCandidate;
|
||||||
|
if (!cand || !this._marqueeEl) return;
|
||||||
|
const r = this.canvas.getBoundingClientRect();
|
||||||
|
cand.curX = e.clientX - r.left;
|
||||||
|
cand.curY = e.clientY - r.top;
|
||||||
|
const x0 = Math.min(cand.startX, cand.curX);
|
||||||
|
const y0 = Math.min(cand.startY, cand.curY);
|
||||||
|
const w = Math.abs(cand.curX - cand.startX);
|
||||||
|
const h = Math.abs(cand.curY - cand.startY);
|
||||||
|
const el = this._marqueeEl;
|
||||||
|
// Оверлей позиционируется относительно canvas.parentElement, поэтому
|
||||||
|
// добавляем offset канваса внутри родителя.
|
||||||
|
el.style.left = (this.canvas.offsetLeft + x0) + 'px';
|
||||||
|
el.style.top = (this.canvas.offsetTop + y0) + 'px';
|
||||||
|
el.style.width = w + 'px';
|
||||||
|
el.style.height = h + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Спроецировать мировую точку в экранные координаты канваса (или null если за камерой). */
|
||||||
|
_projectToScreen(x, y, z) {
|
||||||
|
const engine = this.engine;
|
||||||
|
const w = engine.getRenderWidth();
|
||||||
|
const h = engine.getRenderHeight();
|
||||||
|
const p = Vector3.Project(
|
||||||
|
new Vector3(x, y, z),
|
||||||
|
Matrix.Identity(),
|
||||||
|
this.scene.getTransformMatrix(),
|
||||||
|
{ x: 0, y: 0, width: w, height: h }
|
||||||
|
);
|
||||||
|
// p.z вне [0,1] → точка за ближней/дальней плоскостью (за спиной камеры).
|
||||||
|
if (p.z < 0 || p.z > 1) return null;
|
||||||
|
return { x: p.x, y: p.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Собрать все выделяемые объекты сцены с их центрами. */
|
||||||
|
_collectSelectableObjects() {
|
||||||
|
const out = [];
|
||||||
|
if (this.blockManager) {
|
||||||
|
for (const mesh of this.blockManager.blocks.values()) {
|
||||||
|
const md = mesh.metadata;
|
||||||
|
if (!md?.isBlock) continue;
|
||||||
|
if (md.locked) continue;
|
||||||
|
out.push({ kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ },
|
||||||
|
cx: md.gridX, cy: md.gridY + 0.5, cz: md.gridZ });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.modelManager) {
|
||||||
|
for (const [id, d] of this.modelManager.instances) {
|
||||||
|
if (d.locked) continue;
|
||||||
|
out.push({ kind: 'model', ref: id, cx: d.x || 0, cy: d.y || 0, cz: d.z || 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.primitiveManager) {
|
||||||
|
for (const [id, d] of this.primitiveManager.instances) {
|
||||||
|
if (d.locked) continue;
|
||||||
|
out.push({ kind: 'primitive', ref: id, cx: d.x || 0, cy: d.y || 0, cz: d.z || 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.userModelManager) {
|
||||||
|
for (const [id, d] of this.userModelManager.instances) {
|
||||||
|
if (d.locked) continue;
|
||||||
|
out.push({ kind: 'userModel', ref: id, cx: d.x || 0, cy: d.y || 0, cz: d.z || 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Завершить рамку: отобрать объекты внутри и выставить multi-select. */
|
||||||
|
_endMarquee(e) {
|
||||||
|
const cand = this._marqueeCandidate;
|
||||||
|
const wasActive = this._marqueeActive;
|
||||||
|
this._marqueeCandidate = null;
|
||||||
|
this._marqueeActive = false;
|
||||||
|
if (this._marqueeEl) this._marqueeEl.style.display = 'none';
|
||||||
|
if (!wasActive || !cand) return;
|
||||||
|
|
||||||
|
const minX = Math.min(cand.startX, cand.curX);
|
||||||
|
const maxX = Math.max(cand.startX, cand.curX);
|
||||||
|
const minY = Math.min(cand.startY, cand.curY);
|
||||||
|
const maxY = Math.max(cand.startY, cand.curY);
|
||||||
|
|
||||||
|
const objs = this._collectSelectableObjects();
|
||||||
|
const picked = [];
|
||||||
|
for (const o of objs) {
|
||||||
|
const s = this._projectToScreen(o.cx, o.cy, o.cz);
|
||||||
|
if (!s) continue;
|
||||||
|
if (s.x >= minX && s.x <= maxX && s.y >= minY && s.y <= maxY) {
|
||||||
|
picked.push({ kind: o.kind, ref: o.ref });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.selection) return;
|
||||||
|
// Ctrl при старте рамки → добавляем к уже выделенному, иначе заменяем.
|
||||||
|
this.selection.setMultiSelection(picked, cand.additive);
|
||||||
|
// Привязать групповой гизмо если выбрано >1.
|
||||||
|
const sel = this.selection.getSelection();
|
||||||
|
if (sel?.type === 'multi') {
|
||||||
|
const c = this.selection.getMultiCenter();
|
||||||
|
if (c) this._attachMultiGizmo(c);
|
||||||
|
}
|
||||||
|
this.history?.markChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hover-подсветка (белый контур при наведении, как Roblox Studio) ──────
|
||||||
|
// Наводим мышь на объект → подсвечиваем его белым контуром. Если объект
|
||||||
|
// в папке — подсвечиваем ВСЮ папку (все её меши), как в Roblox Studio.
|
||||||
|
|
||||||
|
/** Запланировать обновление hover на следующий кадр (throttle дорогого pick). */
|
||||||
|
_scheduleHoverUpdate() {
|
||||||
|
if (this._hoverRaf) return;
|
||||||
|
this._hoverRaf = requestAnimationFrame(() => {
|
||||||
|
this._hoverRaf = 0;
|
||||||
|
this._updateHover();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Определить набор мешей под курсором для подсветки + уникальный ключ. */
|
||||||
|
_resolveHoverTarget() {
|
||||||
|
const pick = this._pickFromMouse();
|
||||||
|
if (!pick || !pick.mesh) return null;
|
||||||
|
const m = pick.mesh;
|
||||||
|
// Пол / сетка / ghost / террейн — не подсвечиваем.
|
||||||
|
if (m === this._ghostMesh) return null;
|
||||||
|
if (m.name && (m.name.startsWith('gridLine') || m.name === 'ground')) return null;
|
||||||
|
const md = m.metadata || {};
|
||||||
|
if (md._isTerrainProto || md._isRegionMesh || md._isRobloxTerrain) return null;
|
||||||
|
|
||||||
|
// Определяем тип объекта + его folderId (как в SelectionManager.selectByMesh).
|
||||||
|
let kind = null, id = null, folderId = null;
|
||||||
|
if (md.isBlock) {
|
||||||
|
kind = 'block'; id = `${md.gridX},${md.gridY},${md.gridZ}`;
|
||||||
|
folderId = md.folderId ?? null;
|
||||||
|
} else if (md.isModel) {
|
||||||
|
kind = 'model'; id = md.instanceId;
|
||||||
|
folderId = this.modelManager?.instances.get(id)?.folderId ?? null;
|
||||||
|
} else if (md.isUserModel) {
|
||||||
|
kind = 'userModel'; id = md.instanceId;
|
||||||
|
folderId = this.userModelManager?.instances.get(id)?.folderId ?? null;
|
||||||
|
} else if (md.isPrimitive) {
|
||||||
|
kind = 'primitive'; id = md.primitiveId;
|
||||||
|
folderId = this.primitiveManager?.instances.get(id)?.folderId ?? null;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Объект в папке → подсвечиваем всю папку.
|
||||||
|
if (folderId != null && this.folderManager) {
|
||||||
|
const g = this.folderManager.getFolderObjects(folderId);
|
||||||
|
const meshes = [];
|
||||||
|
for (const mesh of g.meshes) this._collectMeshTree(mesh, meshes);
|
||||||
|
// Блоки папки (у getFolderObjects блоки в g.blocks).
|
||||||
|
for (const bm of (g.blocks || [])) this._collectMeshTree(bm, meshes);
|
||||||
|
return { key: `folder:${folderId}`, meshes };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Одиночный объект → собираем его меши.
|
||||||
|
const meshes = [];
|
||||||
|
if (kind === 'block') {
|
||||||
|
this._collectMeshTree(m, meshes);
|
||||||
|
} else if (kind === 'model') {
|
||||||
|
const d = this.modelManager?.instances.get(id);
|
||||||
|
for (const cm of (d?.clonedMeshes || [])) this._collectMeshTree(cm, meshes);
|
||||||
|
} else if (kind === 'userModel') {
|
||||||
|
const d = this.userModelManager?.instances.get(id);
|
||||||
|
for (const um of (d?.meshes || [])) this._collectMeshTree(um, meshes);
|
||||||
|
} else if (kind === 'primitive') {
|
||||||
|
const d = this.primitiveManager?.instances.get(id);
|
||||||
|
if (d?.mesh) this._collectMeshTree(d.mesh, meshes);
|
||||||
|
}
|
||||||
|
return { key: `${kind}:${id}`, meshes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Добавить mesh и его дочерние меши (только реальные Mesh с геометрией). */
|
||||||
|
_collectMeshTree(node, out) {
|
||||||
|
if (!node) return;
|
||||||
|
// HighlightLayer.addMesh работает только с настоящими Mesh (не TransformNode).
|
||||||
|
if (typeof node.getClassName === 'function'
|
||||||
|
&& (node.getClassName() === 'Mesh' || node.getClassName() === 'InstancedMesh')) {
|
||||||
|
if (node.getTotalVertices?.() > 0) out.push(node);
|
||||||
|
}
|
||||||
|
const kids = node.getChildMeshes?.(false) || [];
|
||||||
|
for (const k of kids) {
|
||||||
|
if (k.getTotalVertices?.() > 0) out.push(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Обновить hover-подсветку под текущим положением курсора. */
|
||||||
|
_updateHover() {
|
||||||
|
if (!this._hoverLayer || this._isPlaying || this._activeTool !== 'select') {
|
||||||
|
this._clearHover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = this._resolveHoverTarget();
|
||||||
|
if (!target || !target.meshes.length) {
|
||||||
|
this._clearHover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Тот же объект — ничего не меняем (throttle лишних addMesh).
|
||||||
|
if (target.key === this._hoverKey) return;
|
||||||
|
this._clearHover();
|
||||||
|
const WHITE = new Color3(1, 1, 1);
|
||||||
|
const seen = new Set();
|
||||||
|
for (const mesh of target.meshes) {
|
||||||
|
if (seen.has(mesh)) continue;
|
||||||
|
seen.add(mesh);
|
||||||
|
try { this._hoverLayer.addMesh(mesh, WHITE); this._hoverMeshes.push(mesh); }
|
||||||
|
catch (e) { /* некоторые меши нельзя добавить — игнор */ }
|
||||||
|
}
|
||||||
|
this._hoverKey = target.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Снять hover-подсветку. */
|
||||||
|
_clearHover() {
|
||||||
|
if (this._hoverLayer && this._hoverMeshes.length) {
|
||||||
|
for (const mesh of this._hoverMeshes) {
|
||||||
|
try { this._hoverLayer.removeMesh(mesh); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._hoverMeshes = [];
|
||||||
|
this._hoverKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Небо (задача 16) — обёртки для game-API и UI редактора ──────────────
|
// ── Небо (задача 16) — обёртки для game-API и UI редактора ──────────────
|
||||||
setSkybox(opts) { this.skybox?.setSkybox(opts); if (this._onSceneChange) this._onSceneChange(); }
|
setSkybox(opts) { this.skybox?.setSkybox(opts); if (this._onSceneChange) this._onSceneChange(); }
|
||||||
setClouds(opts) { this.skybox?.setClouds(opts); if (this._onSceneChange) this._onSceneChange(); }
|
setClouds(opts) { this.skybox?.setClouds(opts); if (this._onSceneChange) this._onSceneChange(); }
|
||||||
@ -8711,6 +9256,23 @@ export class BabylonScene {
|
|||||||
try { this._gizmoLayer.dispose(); } catch (e) { /* ignore */ }
|
try { this._gizmoLayer.dispose(); } catch (e) { /* ignore */ }
|
||||||
this._gizmoLayer = null;
|
this._gizmoLayer = null;
|
||||||
}
|
}
|
||||||
|
if (this._multiPivot) {
|
||||||
|
try { this._multiPivot.dispose(); } catch (e) { /* ignore */ }
|
||||||
|
this._multiPivot = null;
|
||||||
|
}
|
||||||
|
if (this._marqueeEl) {
|
||||||
|
try { this._marqueeEl.remove(); } catch (e) { /* ignore */ }
|
||||||
|
this._marqueeEl = null;
|
||||||
|
}
|
||||||
|
if (this._hoverRaf) {
|
||||||
|
try { cancelAnimationFrame(this._hoverRaf); } catch (e) { /* ignore */ }
|
||||||
|
this._hoverRaf = 0;
|
||||||
|
}
|
||||||
|
if (this._hoverLayer) {
|
||||||
|
try { this._hoverLayer.dispose(); } catch (e) { /* ignore */ }
|
||||||
|
this._hoverLayer = null;
|
||||||
|
this._hoverMeshes = [];
|
||||||
|
}
|
||||||
if (this.selection) {
|
if (this.selection) {
|
||||||
this.selection.dispose();
|
this.selection.dispose();
|
||||||
this.selection = null;
|
this.selection = null;
|
||||||
|
|||||||
@ -826,6 +826,11 @@ export class SelectionManager {
|
|||||||
} else if (it.kind === 'primitive') {
|
} else if (it.kind === 'primitive') {
|
||||||
const data = this.primitiveManager?.instances.get(it.ref);
|
const data = this.primitiveManager?.instances.get(it.ref);
|
||||||
if (data?.mesh) this._highlightMesh(data.mesh);
|
if (data?.mesh) this._highlightMesh(data.mesh);
|
||||||
|
} else if (it.kind === 'userModel') {
|
||||||
|
const data = this.userModelManager?.instances.get(it.ref);
|
||||||
|
if (data?.meshes) {
|
||||||
|
for (const m of data.meshes) this._highlightMesh(m);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -833,6 +838,149 @@ export class SelectionManager {
|
|||||||
/** Получить массив multi-selection. */
|
/** Получить массив multi-selection. */
|
||||||
getMultiSelection() { return [...this._multi]; }
|
getMultiSelection() { return [...this._multi]; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Установить multi-выделение из списка {kind, ref} (рамка выделения).
|
||||||
|
* additive=true — добавить к текущему (Ctrl+рамка), иначе заменить.
|
||||||
|
* Если в итоге 0 объектов — clear; если 1 — обычный single-select.
|
||||||
|
*/
|
||||||
|
setMultiSelection(items, additive = false) {
|
||||||
|
const eq = (a, b) => {
|
||||||
|
if (a.kind !== b.kind) return false;
|
||||||
|
if (a.kind === 'block') return a.ref.x === b.ref.x && a.ref.y === b.ref.y && a.ref.z === b.ref.z;
|
||||||
|
return a.ref === b.ref;
|
||||||
|
};
|
||||||
|
let next;
|
||||||
|
if (additive) {
|
||||||
|
// Стартуем с текущего multi (или текущего single, развёрнутого в элемент).
|
||||||
|
next = [...this._multi];
|
||||||
|
if (next.length === 0 && this._selection) {
|
||||||
|
const s = this._selection;
|
||||||
|
if (s.type === 'block') next.push({ kind: 'block', ref: { x: s.gridX, y: s.gridY, z: s.gridZ } });
|
||||||
|
else if (s.type === 'model') next.push({ kind: 'model', ref: s.instanceId });
|
||||||
|
else if (s.type === 'primitive') next.push({ kind: 'primitive', ref: s.id });
|
||||||
|
else if (s.type === 'userModel') next.push({ kind: 'userModel', ref: s.instanceId });
|
||||||
|
}
|
||||||
|
for (const it of items) {
|
||||||
|
if (!next.some(x => eq(x, it))) next.push(it);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next = [...items];
|
||||||
|
}
|
||||||
|
|
||||||
|
this._removeHighlight();
|
||||||
|
if (next.length === 0) {
|
||||||
|
this._multi = [];
|
||||||
|
this._selection = null;
|
||||||
|
this._notifyChange();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next.length === 1) {
|
||||||
|
this._multi = [];
|
||||||
|
const only = next[0];
|
||||||
|
if (only.kind === 'block') this.selectBlockAt(only.ref.x, only.ref.y, only.ref.z);
|
||||||
|
else if (only.kind === 'model') this.selectModelByInstanceId(only.ref);
|
||||||
|
else if (only.kind === 'primitive') this.selectPrimitiveById(only.ref);
|
||||||
|
else if (only.kind === 'userModel') this.selectUserModelByInstanceId(only.ref);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._multi = next;
|
||||||
|
this._highlightAllMulti();
|
||||||
|
this._selection = { type: 'multi', count: this._multi.length, items: [...this._multi] };
|
||||||
|
this._notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Развернуть multi-элемент в его data + mesh + текущую позицию.
|
||||||
|
* Возвращает { kind, data, mesh, pos:{x,y,z} } или null.
|
||||||
|
*/
|
||||||
|
_resolveMultiItem(it) {
|
||||||
|
if (it.kind === 'block') {
|
||||||
|
const mesh = this.blockManager?.blocks.get(`${it.ref.x},${it.ref.y},${it.ref.z}`);
|
||||||
|
return { kind: 'block', mesh, pos: { x: it.ref.x, y: it.ref.y, z: it.ref.z }, ref: it.ref };
|
||||||
|
}
|
||||||
|
if (it.kind === 'model') {
|
||||||
|
const d = this.modelManager?.instances.get(it.ref);
|
||||||
|
if (!d) return null;
|
||||||
|
return { kind: 'model', data: d, mesh: d.rootMesh, pos: { x: d.x || 0, y: d.y || 0, z: d.z || 0 } };
|
||||||
|
}
|
||||||
|
if (it.kind === 'userModel') {
|
||||||
|
const d = this.userModelManager?.instances.get(it.ref);
|
||||||
|
if (!d) return null;
|
||||||
|
return { kind: 'userModel', data: d, mesh: d.rootNode, pos: { x: d.x || 0, y: d.y || 0, z: d.z || 0 } };
|
||||||
|
}
|
||||||
|
if (it.kind === 'primitive') {
|
||||||
|
const d = this.primitiveManager?.instances.get(it.ref);
|
||||||
|
if (!d) return null;
|
||||||
|
return { kind: 'primitive', data: d, mesh: d.mesh, pos: { x: d.x || 0, y: d.y || 0, z: d.z || 0 } };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Центр multi-выделения (среднее позиций всех объектов). */
|
||||||
|
getMultiCenter() {
|
||||||
|
if (!this._multi.length) return null;
|
||||||
|
let sx = 0, sy = 0, sz = 0, n = 0;
|
||||||
|
for (const it of this._multi) {
|
||||||
|
const r = this._resolveMultiItem(it);
|
||||||
|
if (!r) continue;
|
||||||
|
sx += r.pos.x; sy += r.pos.y; sz += r.pos.z; n++;
|
||||||
|
}
|
||||||
|
if (!n) return null;
|
||||||
|
return { x: sx / n, y: sy / n, z: sz / n };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сдвинуть ВСЕ объекты multi-выделения на (dx,dy,dz).
|
||||||
|
* Блоки переустанавливаются (block-операция через grid-координаты),
|
||||||
|
* модели/примитивы/user-модели двигают свой root-mesh и data.
|
||||||
|
* Блоки двигаем с округлением дельты к целым клеткам (сетка).
|
||||||
|
*/
|
||||||
|
moveMultiBy(dx, dy, dz) {
|
||||||
|
if (!this._multi.length) return;
|
||||||
|
// Блоки: только целочисленный сдвиг по сетке. Накапливаем дробную
|
||||||
|
// часть снаружи (в BabylonScene), сюда приходит уже округлённая для
|
||||||
|
// блоков дельта — но на всякий случай округляем здесь.
|
||||||
|
const bdx = Math.round(dx), bdy = Math.round(dy), bdz = Math.round(dz);
|
||||||
|
const newBlocks = [];
|
||||||
|
for (const it of this._multi) {
|
||||||
|
if (it.kind === 'block') {
|
||||||
|
const { x, y, z } = it.ref;
|
||||||
|
const typeId = this.blockManager?.blocks.get(`${x},${y},${z}`)?.metadata?.blockTypeId;
|
||||||
|
if (typeId == null) continue;
|
||||||
|
this.blockManager.removeBlock(x, y, z);
|
||||||
|
newBlocks.push({ x: x + bdx, y: Math.max(0, y + bdy), z: z + bdz, typeId, ref: it.ref });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const nb of newBlocks) {
|
||||||
|
this.blockManager.addBlock(nb.x, nb.y, nb.z, nb.typeId);
|
||||||
|
// Обновляем ссылку в _multi на новую клетку.
|
||||||
|
nb.ref.x = nb.x; nb.ref.y = nb.y; nb.ref.z = nb.z;
|
||||||
|
}
|
||||||
|
// Модели / примитивы / user-модели — двигаем плавно.
|
||||||
|
for (const it of this._multi) {
|
||||||
|
if (it.kind === 'block') continue;
|
||||||
|
const r = this._resolveMultiItem(it);
|
||||||
|
if (!r || !r.data) continue;
|
||||||
|
r.data.x = (r.data.x || 0) + dx;
|
||||||
|
r.data.y = (r.data.y || 0) + dy;
|
||||||
|
r.data.z = (r.data.z || 0) + dz;
|
||||||
|
if (r.mesh) {
|
||||||
|
if (r.data._worldMatrixFrozen) {
|
||||||
|
try { r.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
|
||||||
|
r.data._worldMatrixFrozen = false;
|
||||||
|
}
|
||||||
|
r.mesh.position.set(r.data.x, r.data.y, r.data.z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Перерисовать подсветку (меши блоков пересозданы).
|
||||||
|
this._removeHighlight();
|
||||||
|
this._highlightAllMulti();
|
||||||
|
this.modelManager?._notifyChange?.();
|
||||||
|
this.primitiveManager?._notifyChange?.();
|
||||||
|
this.userModelManager && this._scene3d?._syncUserModelColliders?.();
|
||||||
|
this._notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
/** Выделить ВСЁ в сцене (Ctrl+A). */
|
/** Выделить ВСЁ в сцене (Ctrl+A). */
|
||||||
selectAll() {
|
selectAll() {
|
||||||
this._removeHighlight();
|
this._removeHighlight();
|
||||||
|
|||||||
@ -76,6 +76,14 @@ export class VehicleManager {
|
|||||||
veh.bodyInstanceId = bodyId;
|
veh.bodyInstanceId = bodyId;
|
||||||
const inst = this._models.instances.get(bodyId);
|
const inst = this._models.instances.get(bodyId);
|
||||||
if (inst && inst.rootMesh) {
|
if (inst && inst.rootMesh) {
|
||||||
|
// Кузов машины — динамический объект: им двигает VehicleManager
|
||||||
|
// (через парентинг к chassisNode). Исключаем из LOD-freeze, иначе
|
||||||
|
// freezeWorldMatrix замораживает меш и он перестаёт следовать за
|
||||||
|
// chassisNode → «едешь на невидимой машине, видимая стоит».
|
||||||
|
// (LOD меряет дистанцию по локальной root.position запарентенного
|
||||||
|
// кузова ≈0,0,0 — некорректно, и замораживает машину.)
|
||||||
|
inst._spawnedAtRuntime = true;
|
||||||
|
inst._isVehicleBody = true;
|
||||||
// Габариты AABB + вертикальный offset кузова СЧИТАЕМ ДО парентинга
|
// Габариты AABB + вертикальный offset кузова СЧИТАЕМ ДО парентинга
|
||||||
// (в мировых координатах, кузов ещё в (x,y,z)).
|
// (в мировых координатах, кузов ещё в (x,y,z)).
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -789,7 +789,8 @@ const KubikonPlayer = () => {
|
|||||||
|| root.webkitRequestFullscreen
|
|| root.webkitRequestFullscreen
|
||||||
|| root.mozRequestFullScreen
|
|| root.mozRequestFullScreen
|
||||||
|| root.msRequestFullscreen;
|
|| root.msRequestFullscreen;
|
||||||
if (req) {
|
// В десктоп-приложении (Electron) окно и так на весь экран — FS не нужен.
|
||||||
|
if (req && !(typeof window !== 'undefined' && window.__RUBLOX_DESKTOP__)) {
|
||||||
try { await req.call(root); } catch (e) { /* отменено */ }
|
try { await req.call(root); } catch (e) { /* отменено */ }
|
||||||
}
|
}
|
||||||
setMobileStartTapped(true);
|
setMobileStartTapped(true);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user