Merge branch 'main' into feat/studio-ui-compact-fullscreen-2026-06-15
All checks were successful
CI / Lint (pull_request) Successful in 1m15s
CI / Build (pull_request) Successful in 1m58s
CI / Secret scan (pull_request) Successful in 23s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped

This commit is contained in:
min 2026-06-15 17:37:45 +00:00
commit b457a29a95
4 changed files with 725 additions and 5 deletions

View File

@ -4,6 +4,7 @@ import { jwtDecode } from 'jwt-decode';
import { useAuth, redirectToLogin } from '../auth/AuthContext.jsx'; import { useAuth, redirectToLogin } from '../auth/AuthContext.jsx';
import { useSanctions } from '../auth/SanctionsContext.jsx'; import { useSanctions } from '../auth/SanctionsContext.jsx';
import { BabylonScene } from './engine/BabylonScene'; import { BabylonScene } from './engine/BabylonScene';
import { MIXAMO_SKINS } from './engine/PlayerController';
import { StudioCollab } from './engine/StudioCollab'; import { StudioCollab } from './engine/StudioCollab';
import { CollabOverlay } from './engine/CollabOverlay'; import { CollabOverlay } from './engine/CollabOverlay';
import { BLOCK_TYPES, BLOCK_CATEGORIES, blockPreview, registerCustomBlockType } from './engine/BlockTypes'; import { BLOCK_TYPES, BLOCK_CATEGORIES, blockPreview, registerCustomBlockType } from './engine/BlockTypes';
@ -1035,7 +1036,7 @@ const KubikonEditor = () => {
// === Game settings inline в TopRibbon (вкладка Тест) === // === Game settings inline в TopRibbon (вкладка Тест) ===
// Дефолт R15-скин bacon-hair (классический Roblox-вид). // Дефолт R15-скин bacon-hair (классический Roblox-вид).
const [playerModelType, setPlayerModelTypeUI] = useState('skin_bacon-hair'); const [playerModelType, setPlayerModelTypeUI] = useState('skin_y-bot');
const [envPreset, setEnvPresetUI] = useState('day'); const [envPreset, setEnvPresetUI] = useState('day');
const [dayDurationMin, setDayDurationMinUI] = useState(5); const [dayDurationMin, setDayDurationMinUI] = useState(5);
const [nightDurationMin, setNightDurationMinUI] = useState(3); const [nightDurationMin, setNightDurationMinUI] = useState(3);
@ -1061,7 +1062,7 @@ const KubikonEditor = () => {
genre: 'other', genre: 'other',
thumbnail: '', thumbnail: '',
is_public: false, is_public: false,
player_model_type: 'skin_bacon-hair', player_model_type: 'skin_y-bot',
}); });
const projectNameRef = useRef(projectName); const projectNameRef = useRef(projectName);
useEffect(() => { projectNameRef.current = projectName; metaRef.current.title = projectName; }, [projectName]); useEffect(() => { projectNameRef.current = projectName; metaRef.current.title = projectName; }, [projectName]);
@ -1879,7 +1880,7 @@ const KubikonEditor = () => {
sceneRef.current.history?.initialize(); sceneRef.current.history?.initialize();
// Синхронизируем UI-state TopRibbon из загруженной сцены // Синхронизируем UI-state TopRibbon из загруженной сцены
try { try {
setPlayerModelTypeUI(sceneRef.current.getPlayerModelType?.() || 'skin_bacon-hair'); setPlayerModelTypeUI(sceneRef.current.getPlayerModelType?.() || 'skin_y-bot');
const env = sceneRef.current.getEnvironmentState?.(); const env = sceneRef.current.getEnvironmentState?.();
if (env?.preset) setEnvPresetUI(env.preset); if (env?.preset) setEnvPresetUI(env.preset);
if (env?.dayDurationMin) setDayDurationMinUI(env.dayDurationMin); if (env?.dayDurationMin) setDayDurationMinUI(env.dayDurationMin);
@ -2190,7 +2191,16 @@ const KubikonEditor = () => {
const uid = getCurrentUserId(); const uid = getCurrentUserId();
if (uid) { if (uid) {
const r = await Kubikon3DApi.getEquippedSkin(uid); const r = await Kubikon3DApi.getEquippedSkin(uid);
const sf = r?.data?.skin_folder; let sf = r?.data?.skin_folder;
// ВАЛИДАЦИЯ: legacy R15-скины (bacon-hair и пр.) больше
// не существуют. Если БД отдала невалидный подменяем
// на skin_y-bot (как в плеере и кабинете).
if (sf && typeof sf === 'string'
&& !MIXAMO_SKINS.has(sf)
&& !sf.startsWith('customskin:')) {
console.log('[KubikonEditor] skin', sf, 'не валиден → skin_y-bot');
sf = 'skin_y-bot';
}
if (sf && typeof sf === 'string') { if (sf && typeof sf === 'string') {
// Подмешиваем в hash так чтобы не сломать ticket=... // Подмешиваем в hash так чтобы не сломать ticket=...
const cur = window.location.hash || ''; const cur = window.location.hash || '';

View File

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

View File

@ -32,7 +32,7 @@ import { MixamoAnimator, loadMixamoAnimations } from './MixamoAnimator';
// Список всех Mixamo-скинов. Должен совпадать со списком в плеере и // Список всех Mixamo-скинов. Должен совпадать со списком в плеере и
// каталоге сайта (rublox-site/src/data/skinsCatalog.js). // каталоге сайта (rublox-site/src/data/skinsCatalog.js).
const MIXAMO_SKINS = new Set([ export const MIXAMO_SKINS = new Set([
'skin_aj', 'skin_akai', 'skin_arissa', 'skin_big-vegas', 'skin_aj', 'skin_akai', 'skin_arissa', 'skin_big-vegas',
'skin_castle-guard-1', 'skin_castle-guard-2', 'skin_castle-guard-1', 'skin_castle-guard-2',
'skin_ch01', 'skin_ch02', 'skin_ch03', 'skin_ch04', 'skin_ch07', 'skin_ch08', 'skin_ch01', 'skin_ch02', 'skin_ch03', 'skin_ch04', 'skin_ch07', 'skin_ch08',

View File

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