feat(studio): Team Create — совместное редактирование игры в реальном времени
StudioCollab (Colyseus studio-room): синхрон операций примитивов/моделей/блоков, presence (курсоры/камера/выделение), soft-lock объектов, перехват менеджеров. CollabOverlay: DOM-курсоры соавторов + онлайн-аватарки + тосты. Кнопки «Скины»+«Пригласить» в TopRibbon вкладка «Игра». Гость-режим (скрыты Настройки/Сохранить/Опубликовать). Autosave только host. Вход по ?collab-токену. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
cb41ea0062
commit
fbf7ef680b
@ -4,12 +4,15 @@ import { jwtDecode } from 'jwt-decode';
|
|||||||
import { useAuth } from '../auth/AuthContext.jsx';
|
import { useAuth } 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 { StudioCollab } from './engine/StudioCollab';
|
||||||
|
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';
|
||||||
import { MODEL_TYPES, MODEL_CATEGORIES, getModelType } from './engine/ModelTypes';
|
import { MODEL_TYPES, MODEL_CATEGORIES, getModelType } from './engine/ModelTypes';
|
||||||
import { getKit } from './engine/GameplayKits';
|
import { getKit } from './engine/GameplayKits';
|
||||||
import { PRIMITIVE_TYPES, PALETTE_PRIMITIVE_TYPES } from './engine/PrimitiveTypes';
|
import { PRIMITIVE_TYPES, PALETTE_PRIMITIVE_TYPES } from './engine/PrimitiveTypes';
|
||||||
import { getModelThumbnail } from './engine/ModelThumbnails';
|
import { getModelThumbnail } from './engine/ModelThumbnails';
|
||||||
import * as Kubikon3DApi from '../api/Kubikon3DService';
|
import * as Kubikon3DApi from '../api/Kubikon3DService';
|
||||||
|
import { REALTIME_HTTP } from '../api/API';
|
||||||
import GameSettingsModal from './GameSettingsModal';
|
import GameSettingsModal from './GameSettingsModal';
|
||||||
import SkinManagerModal from './SkinManagerModal';
|
import SkinManagerModal from './SkinManagerModal';
|
||||||
import PublishModal from './PublishModal';
|
import PublishModal from './PublishModal';
|
||||||
@ -464,6 +467,15 @@ const KubikonEditor = () => {
|
|||||||
|
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const sceneRef = useRef(null);
|
const sceneRef = useRef(null);
|
||||||
|
// Team Create — клиент совместного редактирования + presence-overlay.
|
||||||
|
const collabRef = useRef(null);
|
||||||
|
const collabOverlayRef = useRef(null);
|
||||||
|
const [collabActive, setCollabActive] = useState(false); // подключены к комнате
|
||||||
|
const [collabPeers, setCollabPeers] = useState(0); // сколько ДРУГИХ соавторов
|
||||||
|
// Роль в коллабе: 'owner' (владелец) | 'collab' (приглашённый по ссылке).
|
||||||
|
// Приглашённый — гость: не может менять настройки/сохранять/публиковать.
|
||||||
|
const [collabRole, setCollabRole] = useState('owner');
|
||||||
|
const isInvitedGuest = collabActive && collabRole !== 'owner';
|
||||||
// Флаш pending-debounce ScriptEditor. Зовём перед каждым doSave/перед уходом
|
// Флаш pending-debounce ScriptEditor. Зовём перед каждым doSave/перед уходом
|
||||||
// со страницы — иначе последние 600мс правок скрипта потеряются.
|
// со страницы — иначе последние 600мс правок скрипта потеряются.
|
||||||
const scriptEditorFlushRef = useRef(null);
|
const scriptEditorFlushRef = useRef(null);
|
||||||
@ -1010,6 +1022,14 @@ const KubikonEditor = () => {
|
|||||||
console.warn('[KubikonEditor] save: skip (load failed)');
|
console.warn('[KubikonEditor] save: skip (load failed)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Team Create: в комнате совместного редактирования сохраняет ТОЛЬКО host
|
||||||
|
// (authoritative). Иначе два соавтора autosave'ят наперегонки → last-write-wins
|
||||||
|
// затирает чужие правки. Не-host просто не пишет в БД, его изменения уже
|
||||||
|
// у host через операции.
|
||||||
|
if (collabRef.current?.connected && !collabRef.current?.isHost) {
|
||||||
|
console.log('[KubikonEditor] save: skip (collab non-host, host saves)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const userId = getCurrentUserId();
|
const userId = getCurrentUserId();
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
console.warn('[KubikonEditor] save: no userId');
|
console.warn('[KubikonEditor] save: no userId');
|
||||||
@ -1229,6 +1249,108 @@ const KubikonEditor = () => {
|
|||||||
}, AUTOSAVE_DEBOUNCE_MS);
|
}, AUTOSAVE_DEBOUNCE_MS);
|
||||||
}, [doSave]);
|
}, [doSave]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Team Create: подключиться к комнате совместного редактирования.
|
||||||
|
* Зовётся ПОСЛЕ загрузки сцены (scene готова). projectIdNum — числовой id.
|
||||||
|
* Подключаемся если: владелец проекта (всегда) ИЛИ есть ?collab=<token> в URL.
|
||||||
|
*/
|
||||||
|
const initCollab = useCallback(async (projectIdNum) => {
|
||||||
|
try {
|
||||||
|
if (!sceneRef.current || !projectIdNum) return;
|
||||||
|
if (collabRef.current) return; // уже подключены
|
||||||
|
const collabToken = new URLSearchParams(window.location.search).get('collab') || null;
|
||||||
|
const tokenRaw = localStorage.getItem('Authorization')
|
||||||
|
|| localStorage.getItem('jwt') || '';
|
||||||
|
if (!tokenRaw) return;
|
||||||
|
// Подключаемся только если есть инвайт ИЛИ владелец (бэкенд решит в onAuth).
|
||||||
|
// Если не владелец и нет инвайта — onAuth вернёт 403, ловим тихо.
|
||||||
|
const collab = new StudioCollab(sceneRef.current, {
|
||||||
|
projectId: projectIdNum,
|
||||||
|
token: tokenRaw,
|
||||||
|
collabToken,
|
||||||
|
callbacks: {
|
||||||
|
onConnected: ({ isHost, role }) => {
|
||||||
|
setCollabActive(true);
|
||||||
|
setCollabRole(role || (isHost ? 'owner' : 'collab'));
|
||||||
|
collabOverlayRef.current?.toast(isHost
|
||||||
|
? 'Совместное редактирование включено. Пригласи друга кнопкой 👥'
|
||||||
|
: 'Ты подключился к совместному редактированию!');
|
||||||
|
},
|
||||||
|
onError: (msg) => {
|
||||||
|
// 403 (нет доступа) — норм для не-приглашённого; просто не коллабим.
|
||||||
|
console.warn('[collab] error:', msg);
|
||||||
|
},
|
||||||
|
onLeft: () => { setCollabActive(false); },
|
||||||
|
onPresenceChange: (list) => {
|
||||||
|
collabOverlayRef.current?.updatePresence(list);
|
||||||
|
setCollabPeers(Math.max(0, list.filter(c => !c.me).length));
|
||||||
|
},
|
||||||
|
onOpRejected: (m) => {
|
||||||
|
collabOverlayRef.current?.toast('Этот объект сейчас редактирует другой соавтор');
|
||||||
|
},
|
||||||
|
onChat: (m) => { /* чат соавторов — на этап 2 */ },
|
||||||
|
// host отдаёт текущую сцену новому соавтору
|
||||||
|
onSnapshotRequest: (replyFn) => {
|
||||||
|
try { replyFn(sceneRef.current.serialize()); } catch (e) { /* ignore */ }
|
||||||
|
},
|
||||||
|
// новый соавтор получил сцену от host — грузим
|
||||||
|
onRemoteSnapshot: async (state) => {
|
||||||
|
try {
|
||||||
|
if (state) {
|
||||||
|
await sceneRef.current.loadFromState(state);
|
||||||
|
dirtyRef.current = false;
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('[collab] snapshot load failed', e); }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await collab.connect();
|
||||||
|
collab.installInterceptors();
|
||||||
|
collabRef.current = collab;
|
||||||
|
// presence-overlay
|
||||||
|
const ov = new CollabOverlay(sceneRef.current);
|
||||||
|
ov.mount();
|
||||||
|
collabOverlayRef.current = ov;
|
||||||
|
// курсор-трекинг: шлём точку под мышью на сцене (raycast по pointermove)
|
||||||
|
_wireCursorTracking(sceneRef.current, collab);
|
||||||
|
} catch (e) {
|
||||||
|
// 403/нет доступа/realtime недоступен — работаем соло, не падаем.
|
||||||
|
console.warn('[collab] init skipped:', e?.message || e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Team Create: «Пригласить» — запросить collab-токен у realtime, собрать
|
||||||
|
* ссылку studio.rublox.pro/edit/<id>?collab=<token> и скопировать в буфер.
|
||||||
|
*/
|
||||||
|
const handleInvite = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (!/^\d+$/.test(id)) { alert('Сначала сохрани проект.'); return; }
|
||||||
|
const tokenRaw = localStorage.getItem('Authorization') || localStorage.getItem('jwt') || '';
|
||||||
|
const base = (REALTIME_HTTP || '').replace(/\/$/, '');
|
||||||
|
const res = await fetch(`${base}/studio-invite/${id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: tokenRaw, 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 403) { alert('Только автор проекта может приглашать соавторов.'); return; }
|
||||||
|
alert('Не удалось создать приглашение (' + res.status + ').');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { token } = await res.json();
|
||||||
|
const link = `${window.location.origin}/edit/${id}?collab=${token}`;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(link);
|
||||||
|
collabOverlayRef.current?.toast('Ссылка-приглашение скопирована! Отправь другу.');
|
||||||
|
} catch (e) {
|
||||||
|
window.prompt('Скопируй ссылку-приглашение для друга:', link);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[collab] invite failed', e);
|
||||||
|
alert('Не удалось создать приглашение. Realtime недоступен?');
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
// Инициализация Babylon + загрузка проекта (если редактируем существующий)
|
// Инициализация Babylon + загрузка проекта (если редактируем существующий)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// RACE FIX: пока isLoading=true (auth ещё грузится), компонент
|
// RACE FIX: пока isLoading=true (auth ещё грузится), компонент
|
||||||
@ -1608,6 +1730,9 @@ const KubikonEditor = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
console.log(`[KubikonEditor] setSceneLoading(false) — load finally (id=${id})`);
|
console.log(`[KubikonEditor] setSceneLoading(false) — load finally (id=${id})`);
|
||||||
setSceneLoading(false);
|
setSceneLoading(false);
|
||||||
|
// Team Create: после загрузки сцены — подключиться к комнате
|
||||||
|
// совместного редактирования (владелец или по ?collab-инвайту).
|
||||||
|
if (/^\d+$/.test(id)) initCollab(Number(id));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
} else {
|
} else {
|
||||||
@ -1724,13 +1849,23 @@ const KubikonEditor = () => {
|
|||||||
clearTimeout(autoSaveTimerRef.current);
|
clearTimeout(autoSaveTimerRef.current);
|
||||||
autoSaveTimerRef.current = null;
|
autoSaveTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
// Team Create: отключиться от комнаты + снять overlay.
|
||||||
|
try {
|
||||||
|
if (collabRef.current?.__cursorHandler && collabRef.current?.__cursorCanvas) {
|
||||||
|
collabRef.current.__cursorCanvas.removeEventListener('pointermove', collabRef.current.__cursorHandler);
|
||||||
|
}
|
||||||
|
collabRef.current?.dispose();
|
||||||
|
collabOverlayRef.current?.dispose();
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
collabRef.current = null;
|
||||||
|
collabOverlayRef.current = null;
|
||||||
scene.dispose();
|
scene.dispose();
|
||||||
sceneRef.current = null;
|
sceneRef.current = null;
|
||||||
};
|
};
|
||||||
// isLoading в deps — без него эффект мог стрельнуть пока canvas
|
// isLoading в deps — без него эффект мог стрельнуть пока canvas
|
||||||
// ещё не в DOM (isLoading=true → компонент рендерит null) и больше
|
// ещё не в DOM (isLoading=true → компонент рендерит null) и больше
|
||||||
// не перезапускался → вечная "Загрузка проекта… 0%".
|
// не перезапускался → вечная "Загрузка проекта… 0%".
|
||||||
}, [isAuthenticated, isLoading, id, markDirty]);
|
}, [isAuthenticated, isLoading, id, markDirty, initCollab]);
|
||||||
|
|
||||||
// beforeunload — браузерный диалог нельзя кастомизировать (API запрещает).
|
// beforeunload — браузерный диалог нельзя кастомизировать (API запрещает).
|
||||||
// Auto-save срабатывает на каждое изменение через ~1.5 сек, поэтому риск
|
// Auto-save срабатывает на каждое изменение через ~1.5 сек, поэтому риск
|
||||||
@ -1948,6 +2083,19 @@ const KubikonEditor = () => {
|
|||||||
{saveStatus === 'error' && <><Icon name="warning" size={12} /> Ошибка</>}
|
{saveStatus === 'error' && <><Icon name="warning" size={12} /> Ошибка</>}
|
||||||
{saveStatus === 'idle' && '—'}
|
{saveStatus === 'idle' && '—'}
|
||||||
</span>
|
</span>
|
||||||
|
{/* Гость-соавтор (приглашённый по ссылке) НЕ может менять
|
||||||
|
настройки/сохранять/публиковать — это делает только владелец. */}
|
||||||
|
{isInvitedGuest ? (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '6px 12px', borderRadius: 8,
|
||||||
|
background: 'rgba(92,214,138,0.15)', color: '#5cd68a',
|
||||||
|
font: '600 12px system-ui',
|
||||||
|
}} title="Ты редактируешь вместе с автором. Сохраняет и публикует автор.">
|
||||||
|
<Icon name="users" size={13} /> Совместное редактирование
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
className={cl.toolbarBtn}
|
className={cl.toolbarBtn}
|
||||||
onClick={() => setSettingsModalOpen(true)}
|
onClick={() => setSettingsModalOpen(true)}
|
||||||
@ -1956,14 +2104,7 @@ const KubikonEditor = () => {
|
|||||||
>
|
>
|
||||||
<Icon name="settings" size={13} /> Настройки
|
<Icon name="settings" size={13} /> Настройки
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/* Кнопка «Скины» переехала в TopRibbon → вкладка «Игра». */}
|
||||||
className={cl.toolbarBtn}
|
|
||||||
onClick={() => setSkinManagerOpen(true)}
|
|
||||||
title="Скины игрока: стартовый скин, магазин, рублики, свои .glb"
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
|
||||||
>
|
|
||||||
<Icon name="user-square" size={13} /> Скины
|
|
||||||
</button>
|
|
||||||
<button className={cl.toolbarBtn} onClick={handleSave}><Icon name="save" size={13} /> Сохранить</button>
|
<button className={cl.toolbarBtn} onClick={handleSave}><Icon name="save" size={13} /> Сохранить</button>
|
||||||
<button
|
<button
|
||||||
className={cl.toolbarBtn}
|
className={cl.toolbarBtn}
|
||||||
@ -2003,6 +2144,8 @@ const KubikonEditor = () => {
|
|||||||
>
|
>
|
||||||
{(publishBan || isCantPublish) ? <><Icon name="hidden" size={13} /> Запрещено</> : <><Icon name="upload" size={13} /> Опубликовать</>}
|
{(publishBan || isCantPublish) ? <><Icon name="hidden" size={13} /> Запрещено</> : <><Icon name="upload" size={13} /> Опубликовать</>}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */}
|
{/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */}
|
||||||
<button
|
<button
|
||||||
onClick={() => document.querySelector('[data-kubikon-bug-btn]')?.click()}
|
onClick={() => document.querySelector('[data-kubikon-bug-btn]')?.click()}
|
||||||
@ -2146,6 +2289,10 @@ const KubikonEditor = () => {
|
|||||||
sceneRef.current?.setSpawnAtCamera();
|
sceneRef.current?.setSpawnAtCamera();
|
||||||
setSpawnEnabledUI(true);
|
setSpawnEnabledUI(true);
|
||||||
}}
|
}}
|
||||||
|
onSkins={() => setSkinManagerOpen(true)}
|
||||||
|
onInvite={handleInvite}
|
||||||
|
collabActive={collabActive}
|
||||||
|
collabPeers={collabPeers}
|
||||||
hasSelection={!!selection}
|
hasSelection={!!selection}
|
||||||
onDuplicate={() => sceneRef.current?.duplicateSelected()}
|
onDuplicate={() => sceneRef.current?.duplicateSelected()}
|
||||||
onAlignToFloor={() => sceneRef.current?.alignSelectedToFloor()}
|
onAlignToFloor={() => sceneRef.current?.alignSelectedToFloor()}
|
||||||
@ -2783,6 +2930,7 @@ const KubikonEditor = () => {
|
|||||||
className={cl.canvas}
|
className={cl.canvas}
|
||||||
style={{ visibility: activeTabId === 'scene' ? 'visible' : 'hidden' }}
|
style={{ visibility: activeTabId === 'scene' ? 'visible' : 'hidden' }}
|
||||||
/>
|
/>
|
||||||
|
{/* Кнопка «Пригласить» переехала в TopRibbon → вкладка «Игра» → группа «Вместе». */}
|
||||||
{isMaterialPreview && (
|
{isMaterialPreview && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', top: 10,
|
position: 'absolute', top: 10,
|
||||||
@ -4012,4 +4160,34 @@ const KubikonEditor = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Team Create: слать соавторам точку под мышью на сцене (raycast по pointermove).
|
||||||
|
* Throttle уже внутри collab.sendCursor. Также шлём позицию камеры.
|
||||||
|
*/
|
||||||
|
function _wireCursorTracking(scene, collab) {
|
||||||
|
try {
|
||||||
|
const canvas = scene.canvas;
|
||||||
|
if (!canvas) return;
|
||||||
|
const onMove = () => {
|
||||||
|
const bScene = scene.scene;
|
||||||
|
if (!bScene) return;
|
||||||
|
try {
|
||||||
|
const pick = bScene.pick(bScene.pointerX, bScene.pointerY);
|
||||||
|
if (pick && pick.hit && pick.pickedPoint) {
|
||||||
|
collab.sendCursor(pick.pickedPoint.x, pick.pickedPoint.y, pick.pickedPoint.z);
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
// камера (throttle внутри)
|
||||||
|
try {
|
||||||
|
const c = scene.camera;
|
||||||
|
if (c && c.position) collab.sendCamera(c.position.x, c.position.y, c.position.z);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
};
|
||||||
|
canvas.addEventListener('pointermove', onMove);
|
||||||
|
// сохраним для снятия (необязательно — canvas живёт с редактором)
|
||||||
|
collab.__cursorHandler = onMove;
|
||||||
|
collab.__cursorCanvas = canvas;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
export default KubikonEditor;
|
export default KubikonEditor;
|
||||||
|
|||||||
@ -233,6 +233,7 @@ const TopRibbon = (props) => {
|
|||||||
snap, onSnapChange,
|
snap, onSnapChange,
|
||||||
activeTool, onToolChange,
|
activeTool, onToolChange,
|
||||||
isPlaying, onPlayToggle, onSetSpawn,
|
isPlaying, onPlayToggle, onSetSpawn,
|
||||||
|
onSkins, onInvite, collabActive, collabPeers,
|
||||||
hasSelection,
|
hasSelection,
|
||||||
onDuplicate, onAlignToFloor, onDelete,
|
onDuplicate, onAlignToFloor, onDelete,
|
||||||
onClearScene,
|
onClearScene,
|
||||||
@ -431,6 +432,22 @@ const TopRibbon = (props) => {
|
|||||||
onClick={onSetSpawn}
|
onClick={onSetSpawn}
|
||||||
title="Поставить точку спавна там где смотрит камера"
|
title="Поставить точку спавна там где смотрит камера"
|
||||||
/>
|
/>
|
||||||
|
<RibbonBtn
|
||||||
|
iconName="user-square" label="Скины"
|
||||||
|
onClick={onSkins}
|
||||||
|
title="Скины игрока: стартовый скин, магазин, рублики, свои .glb"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Team Create — совместное редактирование. */}
|
||||||
|
<Group title="Вместе">
|
||||||
|
<RibbonBtn
|
||||||
|
iconName="users"
|
||||||
|
label={collabActive && collabPeers > 0 ? `Вместе (${collabPeers + 1})` : 'Пригласить'}
|
||||||
|
active={collabActive && collabPeers > 0}
|
||||||
|
onClick={onInvite}
|
||||||
|
title="Пригласить друга редактировать игру вместе (Team Create)"
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* «Окружение» (время суток / амбиент / музыка) и
|
{/* «Окружение» (время суток / амбиент / музыка) и
|
||||||
|
|||||||
180
src/editor/engine/CollabOverlay.js
Normal file
180
src/editor/engine/CollabOverlay.js
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* CollabOverlay — DOM-слой совместного редактирования (Team Create).
|
||||||
|
*
|
||||||
|
* Рисует поверх canvas студии:
|
||||||
|
* - панель «онлайн» (аватарки-кружки соавторов с цветом и ником);
|
||||||
|
* - 3D-курсоры соавторов (точка с ником, спроецированная из мировых
|
||||||
|
* координат в экранные);
|
||||||
|
* - подсветку чужого выделения (по selectedKey — обводим объект);
|
||||||
|
* - тосты («объект занят другим», «N присоединился»).
|
||||||
|
*
|
||||||
|
* Самодостаточный (как LoadingScreenOverlay): монтируется на parent canvas,
|
||||||
|
* UI обновляется методами update(...). Кнопка «Пригласить» — снаружи (TopRibbon),
|
||||||
|
* здесь только presence + курсоры.
|
||||||
|
*/
|
||||||
|
import { Vector3, Matrix } from '@babylonjs/core';
|
||||||
|
|
||||||
|
let _cssInjected = false;
|
||||||
|
function injectCss() {
|
||||||
|
if (_cssInjected || typeof document === 'undefined') return;
|
||||||
|
_cssInjected = true;
|
||||||
|
const s = document.createElement('style');
|
||||||
|
s.id = 'kbn-collab-css';
|
||||||
|
s.textContent =
|
||||||
|
'.kbnCollabCursor{position:absolute;transform:translate(-50%,-50%);pointer-events:none;z-index:62;transition:left 0.08s linear,top 0.08s linear;will-change:left,top}' +
|
||||||
|
'.kbnCollabDot{width:14px;height:14px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.5)}' +
|
||||||
|
'.kbnCollabName{position:absolute;left:16px;top:-2px;white-space:nowrap;font:600 11px system-ui;color:#fff;padding:1px 6px;border-radius:6px;text-shadow:0 1px 2px rgba(0,0,0,.6)}' +
|
||||||
|
'.kbnCollabBar{position:absolute;top:10px;left:50%;transform:translateX(-50%);z-index:63;display:flex;gap:6px;align-items:center;background:rgba(20,24,38,.78);padding:5px 10px;border-radius:20px;backdrop-filter:blur(6px);font:600 12px system-ui;color:#cdd6e6;pointer-events:auto}' +
|
||||||
|
'.kbnCollabAva{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font:700 12px system-ui;color:#10131c;border:2px solid rgba(255,255,255,.25)}' +
|
||||||
|
'.kbnCollabToast{position:absolute;bottom:80px;left:50%;transform:translateX(-50%);z-index:64;background:rgba(20,24,38,.92);color:#fff;padding:10px 18px;border-radius:10px;font:600 14px system-ui;box-shadow:0 8px 24px rgba(0,0,0,.5);opacity:0;transition:opacity .25s}';
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CollabOverlay {
|
||||||
|
constructor(scene) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.root = null;
|
||||||
|
this.barEl = null;
|
||||||
|
this.cursorsEl = null;
|
||||||
|
this.toastEl = null;
|
||||||
|
this._cursors = new Map(); // sessionId → {wrap, dot, name}
|
||||||
|
this._presence = [];
|
||||||
|
this._raf = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
injectCss();
|
||||||
|
const parent = (this.scene.canvas && this.scene.canvas.parentElement) || document.body;
|
||||||
|
try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
const root = document.createElement('div');
|
||||||
|
root.className = 'kbn-collab-root';
|
||||||
|
root.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:60;overflow:hidden';
|
||||||
|
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'kbnCollabBar';
|
||||||
|
bar.innerHTML = '<span style="opacity:.8">👥 Соавторы:</span>';
|
||||||
|
|
||||||
|
const cursors = document.createElement('div');
|
||||||
|
cursors.style.cssText = 'position:absolute;inset:0';
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'kbnCollabToast';
|
||||||
|
|
||||||
|
root.appendChild(bar);
|
||||||
|
root.appendChild(cursors);
|
||||||
|
root.appendChild(toast);
|
||||||
|
parent.appendChild(root);
|
||||||
|
|
||||||
|
this.root = root; this.barEl = bar; this.cursorsEl = cursors; this.toastEl = toast;
|
||||||
|
this._startLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Обновить список соавторов (из StudioCollab.onPresenceChange). */
|
||||||
|
updatePresence(list) {
|
||||||
|
this._presence = list || [];
|
||||||
|
if (!this.barEl) return;
|
||||||
|
// Перерисовать аватарки.
|
||||||
|
const others = this._presence;
|
||||||
|
let html = '<span style="opacity:.8">👥</span>';
|
||||||
|
for (const c of others) {
|
||||||
|
const initial = (c.username || '?').trim().charAt(0).toUpperCase() || '?';
|
||||||
|
const ring = c.me ? 'box-shadow:0 0 0 2px #fff' : '';
|
||||||
|
html += `<div class="kbnCollabAva" title="${escapeHtml(c.username)}${c.isHost ? ' (хост)' : ''}${c.me ? ' — вы' : ''}" style="background:${c.color};${ring}">${escapeHtml(initial)}</div>`;
|
||||||
|
}
|
||||||
|
html += `<span style="opacity:.7;margin-left:4px">${others.length}</span>`;
|
||||||
|
this.barEl.innerHTML = html;
|
||||||
|
// Курсоры пересоберём в loop.
|
||||||
|
this._syncCursorEls();
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncCursorEls() {
|
||||||
|
const present = new Set(this._presence.filter(c => !c.me).map(c => c.sessionId));
|
||||||
|
// удалить ушедших
|
||||||
|
for (const [sid, el] of this._cursors) {
|
||||||
|
if (!present.has(sid)) { try { el.wrap.remove(); } catch (e) { /* ignore */ } this._cursors.delete(sid); }
|
||||||
|
}
|
||||||
|
// добавить новых
|
||||||
|
for (const c of this._presence) {
|
||||||
|
if (c.me) continue;
|
||||||
|
if (!this._cursors.has(c.sessionId)) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'kbnCollabCursor';
|
||||||
|
const dot = document.createElement('div');
|
||||||
|
dot.className = 'kbnCollabDot';
|
||||||
|
const name = document.createElement('div');
|
||||||
|
name.className = 'kbnCollabName';
|
||||||
|
wrap.appendChild(dot); wrap.appendChild(name);
|
||||||
|
this.cursorsEl.appendChild(wrap);
|
||||||
|
this._cursors.set(c.sessionId, { wrap, dot, name });
|
||||||
|
}
|
||||||
|
const el = this._cursors.get(c.sessionId);
|
||||||
|
el.dot.style.background = c.color;
|
||||||
|
el.name.style.background = c.color;
|
||||||
|
el.name.textContent = c.username;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Каждый кадр проецируем 3D-курсоры соавторов в экранные координаты. */
|
||||||
|
_startLoop() {
|
||||||
|
const tick = () => {
|
||||||
|
this._raf = requestAnimationFrame(tick);
|
||||||
|
if (!this.cursorsEl || !this._presence.length) return;
|
||||||
|
const scene = this.scene.scene;
|
||||||
|
const cam = this.scene.camera;
|
||||||
|
const eng = this.scene.engine;
|
||||||
|
if (!scene || !cam || !eng) return;
|
||||||
|
for (const c of this._presence) {
|
||||||
|
if (c.me) continue;
|
||||||
|
const el = this._cursors.get(c.sessionId);
|
||||||
|
if (!el) continue;
|
||||||
|
const cur = c.cursor;
|
||||||
|
if (!cur || (cur.x === 0 && cur.y === 0 && cur.z === 0)) { el.wrap.style.display = 'none'; continue; }
|
||||||
|
const sp = this._project(cur.x, cur.y, cur.z, scene, cam, eng);
|
||||||
|
if (!sp) { el.wrap.style.display = 'none'; continue; }
|
||||||
|
el.wrap.style.display = 'block';
|
||||||
|
el.wrap.style.left = sp.x + 'px';
|
||||||
|
el.wrap.style.top = sp.y + 'px';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._raf = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Спроецировать мировую точку в экранные координаты canvas. */
|
||||||
|
_project(x, y, z, scene, cam, eng) {
|
||||||
|
try {
|
||||||
|
const w = eng.getRenderWidth();
|
||||||
|
const h = eng.getRenderHeight();
|
||||||
|
const p = Vector3.Project(
|
||||||
|
new Vector3(x, y, z),
|
||||||
|
Matrix.Identity(),
|
||||||
|
scene.getTransformMatrix(),
|
||||||
|
cam.viewport.toGlobal(w, h)
|
||||||
|
);
|
||||||
|
if (p.z < 0 || p.z > 1) return null; // за камерой
|
||||||
|
// переводим из render-пикселей в css-пиксели canvas
|
||||||
|
const canvas = eng.getRenderingCanvas();
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const sx = rect.width / w, sy = rect.height / h;
|
||||||
|
return { x: p.x * sx, y: p.y * sy };
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
toast(text) {
|
||||||
|
if (!this.toastEl) return;
|
||||||
|
this.toastEl.textContent = text;
|
||||||
|
this.toastEl.style.opacity = '1';
|
||||||
|
clearTimeout(this._toastT);
|
||||||
|
this._toastT = setTimeout(() => { if (this.toastEl) this.toastEl.style.opacity = '0'; }, 2600);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this._raf) cancelAnimationFrame(this._raf);
|
||||||
|
try { this.root?.remove(); } catch (e) { /* ignore */ }
|
||||||
|
this.root = null; this._cursors.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
|
||||||
|
}
|
||||||
502
src/editor/engine/StudioCollab.js
Normal file
502
src/editor/engine/StudioCollab.js
Normal file
@ -0,0 +1,502 @@
|
|||||||
|
/**
|
||||||
|
* StudioCollab — клиент совместного редактирования (Team Create) для студии.
|
||||||
|
*
|
||||||
|
* Подключается к Colyseus-комнате `studio` (одна на projectId) и:
|
||||||
|
* 1. ОТПРАВЛЯЕТ операции локального юзера (add/move/delete/setColor/...) —
|
||||||
|
* вызывается из обёрток в KubikonEditor/менеджерах через collab.sendOp(...).
|
||||||
|
* 2. ПРИНИМАЕТ операции других соавторов и применяет их к сцене
|
||||||
|
* (через applyRemoteOp → менеджеры BabylonScene), с флагом _fromRemote,
|
||||||
|
* чтобы не отправить их обратно (анти-эхо).
|
||||||
|
* 3. PRESENCE: рисует курсоры/выделение/онлайн-список соавторов
|
||||||
|
* (через колбэки onPresence — UI-слой подписывается).
|
||||||
|
* 4. SOFT-LOCK: select/deselect объекта; залоченный другим объект не дать
|
||||||
|
* двигать (onLockRejected колбэк для UI-уведомления).
|
||||||
|
*
|
||||||
|
* Транспорт переиспользует существующий kubikon-realtime (Colyseus 0.16).
|
||||||
|
* В Colyseus 0.16 state-колбэки идут через getStateCallbacks(room) (см.
|
||||||
|
* feedback_colyseus_016_api).
|
||||||
|
*/
|
||||||
|
import { Client, getStateCallbacks } from 'colyseus.js';
|
||||||
|
import { REALTIME_WS } from '../../api/API';
|
||||||
|
|
||||||
|
export class StudioCollab {
|
||||||
|
/**
|
||||||
|
* @param {object} scene — BabylonScene (со всеми менеджерами)
|
||||||
|
* @param {object} opts — { projectId, token, collabToken?, callbacks }
|
||||||
|
* callbacks: {
|
||||||
|
* onConnected({sessionId,color,isHost}), onError(msg), onLeft(),
|
||||||
|
* onPresenceChange(list), onOpRejected({key,by}),
|
||||||
|
* onSnapshotRequest(replyFn), onRemoteSnapshot(state), onChat(msg),
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
constructor(scene, opts = {}) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.projectId = opts.projectId;
|
||||||
|
this.token = opts.token;
|
||||||
|
this.collabToken = opts.collabToken || null;
|
||||||
|
this.cb = opts.callbacks || {};
|
||||||
|
this.room = null;
|
||||||
|
this.client = null;
|
||||||
|
this.sessionId = null;
|
||||||
|
this.color = '#5fd0ff';
|
||||||
|
this.isHost = false;
|
||||||
|
this.connected = false;
|
||||||
|
this._applyingRemote = false; // флаг анти-эхо
|
||||||
|
this._cursorThrottle = 0;
|
||||||
|
this._camThrottle = 0;
|
||||||
|
this._destroyed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true если сейчас применяем удалённую операцию (не слать обратно). */
|
||||||
|
get applyingRemote() { return this._applyingRemote; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обернуть методы менеджеров, чтобы ЛЮБОЕ изменение сцены (из любого
|
||||||
|
* UI-пути) транслировалось соавторам. Анти-эхо: при _applyingRemote
|
||||||
|
* (мы применяем чужую операцию) обёртки молчат.
|
||||||
|
*
|
||||||
|
* Перехватываем минимальный набор для MVP:
|
||||||
|
* PrimitiveManager: addInstance, updateInstance, removeInstance
|
||||||
|
* ModelManager: addInstance, removeInstance
|
||||||
|
*/
|
||||||
|
installInterceptors() {
|
||||||
|
const self = this;
|
||||||
|
const pm = this.scene.primitiveManager;
|
||||||
|
const mm = this.scene.modelManager;
|
||||||
|
if (pm && !pm.__collabPatched) {
|
||||||
|
pm.__collabPatched = true;
|
||||||
|
const origAdd = pm.addInstance.bind(pm);
|
||||||
|
pm.addInstance = function (type, opts = {}) {
|
||||||
|
const id = origAdd(type, opts);
|
||||||
|
if (id != null && !self._applyingRemote) {
|
||||||
|
// Шлём полный снимок объекта (с id), чтобы у соавтора создался
|
||||||
|
// ТОТ ЖЕ instanceId — иначе move-операции не совпадут.
|
||||||
|
const data = pm.instances?.get?.(id);
|
||||||
|
const def = serializePrimitive(data, id, type);
|
||||||
|
self.sendOp({ op: 'add', key: 'primitive:' + id, def });
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
const origUpd = pm.updateInstance.bind(pm);
|
||||||
|
pm.updateInstance = function (id, patch) {
|
||||||
|
const r = origUpd(id, patch);
|
||||||
|
if (!self._applyingRemote && patch && typeof patch === 'object') {
|
||||||
|
self.sendOp(patchToOp('primitive:' + id, patch));
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
const origRem = pm.removeInstance.bind(pm);
|
||||||
|
pm.removeInstance = function (id) {
|
||||||
|
const r = origRem(id);
|
||||||
|
if (!self._applyingRemote) self.sendOp({ op: 'delete', key: 'primitive:' + id });
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// BlockManager — синхрон блоков (addBlock/removeBlock).
|
||||||
|
const bm = this.scene.blockManager;
|
||||||
|
if (bm && !bm.__collabPatched) {
|
||||||
|
bm.__collabPatched = true;
|
||||||
|
if (typeof bm.addBlock === 'function') {
|
||||||
|
const origAddB = bm.addBlock.bind(bm);
|
||||||
|
bm.addBlock = function (x, y, z, blockTypeId, color) {
|
||||||
|
const r = origAddB(x, y, z, blockTypeId, color);
|
||||||
|
if (!self._applyingRemote) {
|
||||||
|
self.sendOp({ op: 'blockAdd', x, y, z, blockTypeId, color });
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof bm.removeBlock === 'function') {
|
||||||
|
const origRemB = bm.removeBlock.bind(bm);
|
||||||
|
bm.removeBlock = function (x, y, z) {
|
||||||
|
const r = origRemB(x, y, z);
|
||||||
|
if (!self._applyingRemote) self.sendOp({ op: 'blockRemove', x, y, z });
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectionManager — берём/снимаем soft-lock при выделении.
|
||||||
|
const sm = this.scene.selectionManager || this.scene.selection;
|
||||||
|
if (sm && !sm.__collabPatched) {
|
||||||
|
sm.__collabPatched = true;
|
||||||
|
const sendSel = () => {
|
||||||
|
if (self._applyingRemote) return;
|
||||||
|
const s = sm._selection;
|
||||||
|
if (s && s.type && s.id != null) {
|
||||||
|
self.selectObject(s.type + ':' + s.id);
|
||||||
|
} else {
|
||||||
|
self.deselectObject();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
['selectByMesh', 'selectPrimitiveById'].forEach((fn) => {
|
||||||
|
if (typeof sm[fn] === 'function') {
|
||||||
|
const orig = sm[fn].bind(sm);
|
||||||
|
sm[fn] = function (...a) { const r = orig(...a); sendSel(); return r; };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (typeof sm.clear === 'function') {
|
||||||
|
const origClear = sm.clear.bind(sm);
|
||||||
|
sm.clear = function (...a) { const r = origClear(...a); if (!self._applyingRemote) self.deselectObject(); return r; };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mm && !mm.__collabPatched) {
|
||||||
|
mm.__collabPatched = true;
|
||||||
|
const origAddM = mm.addInstance.bind(mm);
|
||||||
|
mm.addInstance = function (modelTypeId, x, y, z, rotationY = 0) {
|
||||||
|
const ret = origAddM(modelTypeId, x, y, z, rotationY);
|
||||||
|
// addInstance модели async → ret это Promise<id>.
|
||||||
|
Promise.resolve(ret).then((id) => {
|
||||||
|
if (id != null && !self._applyingRemote) {
|
||||||
|
self.sendOp({ op: 'add', key: 'model:' + id,
|
||||||
|
def: { __kind: 'model', modelTypeId, x, y, z, rotationY } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
const origRemM = mm.removeInstance.bind(mm);
|
||||||
|
mm.removeInstance = function (id) {
|
||||||
|
const r = origRemM(id);
|
||||||
|
if (!self._applyingRemote) self.sendOp({ op: 'delete', key: 'model:' + id });
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Снять обёртки (при выходе из коллаба восстановить оригиналы — простой флаг). */
|
||||||
|
uninstallInterceptors() {
|
||||||
|
// Оставляем обёртки, но они станут no-op после dispose (connected=false).
|
||||||
|
// Полный un-patch не нужен: sendOp молчит при !connected.
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
this.client = new Client(REALTIME_WS);
|
||||||
|
const joinOpts = {
|
||||||
|
projectId: this.projectId,
|
||||||
|
token: this.token,
|
||||||
|
};
|
||||||
|
if (this.collabToken) joinOpts.collabToken = this.collabToken;
|
||||||
|
this.room = await this.client.joinOrCreate('studio', joinOpts);
|
||||||
|
this.connected = true;
|
||||||
|
this.sessionId = this.room.sessionId;
|
||||||
|
|
||||||
|
const room = this.room;
|
||||||
|
// --- сервер сообщил наши параметры ---
|
||||||
|
room.onMessage('joined', (m) => {
|
||||||
|
this.sessionId = m.sessionId;
|
||||||
|
this.color = m.color;
|
||||||
|
this.isHost = !!m.isHost;
|
||||||
|
this.cb.onConnected?.({ sessionId: m.sessionId, color: m.color, isHost: !!m.isHost, role: m.role });
|
||||||
|
// Новый соавтор грузит сцену из БД сам (как при обычном открытии
|
||||||
|
// проекта), дальше догоняет live-операции. Снапшот через комнату НЕ
|
||||||
|
// шлём — большая сцена превышает лимит payload Colyseus (RangeError).
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- входящая операция от другого соавтора ---
|
||||||
|
room.onMessage('op', (m) => {
|
||||||
|
if (!m || m.from === this.sessionId) return;
|
||||||
|
this._applyRemoteOp(m.op);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- наша операция отклонена (объект залочен) ---
|
||||||
|
room.onMessage('op-rejected', (m) => { this.cb.onOpRejected?.(m); });
|
||||||
|
room.onMessage('select-denied', (m) => { this.cb.onOpRejected?.(m); });
|
||||||
|
|
||||||
|
// --- чат соавторов ---
|
||||||
|
room.onMessage('chat', (m) => { this.cb.onChat?.(m); });
|
||||||
|
|
||||||
|
// --- presence через state ---
|
||||||
|
this._wirePresence();
|
||||||
|
|
||||||
|
room.onLeave(() => { this.connected = false; this.cb.onLeft?.(); });
|
||||||
|
room.onError((code, msg) => { this.cb.onError?.(msg || ('Ошибка ' + code)); });
|
||||||
|
|
||||||
|
return this.room;
|
||||||
|
}
|
||||||
|
|
||||||
|
_wirePresence() {
|
||||||
|
const $ = getStateCallbacks(this.room);
|
||||||
|
const state = this.room.state;
|
||||||
|
const emit = () => {
|
||||||
|
if (this._destroyed) return;
|
||||||
|
const list = [];
|
||||||
|
const col = state && state.collaborators;
|
||||||
|
if (col && typeof col.forEach === 'function') {
|
||||||
|
col.forEach((c, sid) => {
|
||||||
|
list.push({
|
||||||
|
sessionId: sid,
|
||||||
|
me: sid === this.sessionId,
|
||||||
|
username: c.username,
|
||||||
|
color: c.color,
|
||||||
|
cursor: { x: c.cursorX, y: c.cursorY, z: c.cursorZ },
|
||||||
|
cam: { x: c.camX, y: c.camY, z: c.camZ },
|
||||||
|
selectedKey: c.selectedKey,
|
||||||
|
isHost: sid === state.hostSessionId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.cb.onPresenceChange?.(list);
|
||||||
|
};
|
||||||
|
// Подписки оборачиваем в try — в Colyseus 0.16 API колбэков для
|
||||||
|
// MapSchema(string) может отличаться от MapSchema(Schema); не валим коннект.
|
||||||
|
try {
|
||||||
|
$(state).collaborators.onAdd((c, _sid) => {
|
||||||
|
try { $(c).onChange(() => emit()); } catch (e) { /* ignore */ }
|
||||||
|
emit();
|
||||||
|
});
|
||||||
|
$(state).collaborators.onRemove(() => emit());
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
try {
|
||||||
|
$(state).locks.onAdd(() => emit());
|
||||||
|
$(state).locks.onRemove(() => emit());
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ ИСХОДЯЩИЕ ============
|
||||||
|
|
||||||
|
/** Отправить операцию (если подключены и это не эхо удалённой).
|
||||||
|
* Для непрерывных op (move/rotate/scale) — throttle ~30/сек по ключу,
|
||||||
|
* чтобы драг гизмо (60fps) не залил комнату. add/delete/setColor — сразу. */
|
||||||
|
sendOp(op) {
|
||||||
|
if (!this.connected || this._applyingRemote) return;
|
||||||
|
const cont = op && (op.op === 'move' || op.op === 'rotate' || op.op === 'scale');
|
||||||
|
if (cont) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!this._opThrottle) this._opThrottle = {};
|
||||||
|
const tk = op.op + ':' + op.key;
|
||||||
|
if (now - (this._opThrottle[tk] || 0) < 33) {
|
||||||
|
// запомним последнее значение, дошлём в _flushPending через rAF
|
||||||
|
this._pendingOps = this._pendingOps || {};
|
||||||
|
this._pendingOps[tk] = op;
|
||||||
|
this._scheduleFlush();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._opThrottle[tk] = now;
|
||||||
|
}
|
||||||
|
try { this.room.send('op', op); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Догнать «зажатые» throttle-операции (последнее значение драга — важно
|
||||||
|
* чтобы финальная позиция точно дошла, а не оборвалась на середине). */
|
||||||
|
_scheduleFlush() {
|
||||||
|
if (this._flushScheduled) return;
|
||||||
|
this._flushScheduled = true;
|
||||||
|
const run = () => {
|
||||||
|
this._flushScheduled = false;
|
||||||
|
const pend = this._pendingOps; this._pendingOps = null;
|
||||||
|
if (!pend || !this.connected) return;
|
||||||
|
const now = Date.now();
|
||||||
|
for (const tk in pend) {
|
||||||
|
const op = pend[tk];
|
||||||
|
this._opThrottle[tk] = now;
|
||||||
|
try { this.room.send('op', op); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (typeof requestAnimationFrame === 'function') setTimeout(run, 40);
|
||||||
|
else setTimeout(run, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Курсор-точка под мышью (throttle ~20/сек). */
|
||||||
|
sendCursor(x, y, z) {
|
||||||
|
if (!this.connected) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this._cursorThrottle < 50) return;
|
||||||
|
this._cursorThrottle = now;
|
||||||
|
try { this.room.send('cursor', { x, y, z }); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCamera(x, y, z) {
|
||||||
|
if (!this.connected) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this._camThrottle < 200) return;
|
||||||
|
this._camThrottle = now;
|
||||||
|
try { this.room.send('camera', { x, y, z }); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выделить объект (берёт soft-lock). key = 'primitive:42' / 'model:m3'. */
|
||||||
|
selectObject(key) {
|
||||||
|
if (!this.connected) return;
|
||||||
|
try { this.room.send('select', { key: key || '' }); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
deselectObject() {
|
||||||
|
if (!this.connected) return;
|
||||||
|
try { this.room.send('deselect', {}); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
sendChat(text) {
|
||||||
|
if (!this.connected) return;
|
||||||
|
try { this.room.send('chat', { text }); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ ВХОДЯЩИЕ → СЦЕНА ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применить операцию другого соавтора к локальной сцене.
|
||||||
|
* Оборачиваем во флаг _applyingRemote, чтобы обёртки менеджеров НЕ
|
||||||
|
* отправили её обратно в комнату (анти-эхо).
|
||||||
|
*/
|
||||||
|
_applyRemoteOp(op) {
|
||||||
|
if (!op || typeof op !== 'object') return;
|
||||||
|
this._applyingRemote = true;
|
||||||
|
try {
|
||||||
|
applyRemoteOp(this.scene, op);
|
||||||
|
} catch (e) {
|
||||||
|
// не валим соединение из-за одной кривой операции
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[StudioCollab] applyRemoteOp failed:', op?.op, e);
|
||||||
|
} finally {
|
||||||
|
this._applyingRemote = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this._destroyed = true;
|
||||||
|
this.connected = false;
|
||||||
|
try { this.room?.leave(true); } catch (e) { /* ignore */ }
|
||||||
|
this.room = null;
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применить удалённую операцию к сцене через менеджеры BabylonScene.
|
||||||
|
* Каждый тип op маппится на метод менеджера. Если метода нет — тихо
|
||||||
|
* пропускаем (фича-парность движков: на этапе 1 поддерживаем базовый набор).
|
||||||
|
*
|
||||||
|
* Поддерживаемые операции (этап 1):
|
||||||
|
* add { kind:'primitive'|'model', def } — создать объект
|
||||||
|
* move { key, x, y, z } — переместить
|
||||||
|
* rotate { key, rx, ry, rz } — повернуть
|
||||||
|
* scale { key, sx, sy, sz } — масштаб
|
||||||
|
* setColor { key, color } — цвет
|
||||||
|
* setProp { key, prop, value } — произвольное свойство
|
||||||
|
* delete { key } — удалить
|
||||||
|
*/
|
||||||
|
export function applyRemoteOp(scene, op) {
|
||||||
|
const t = op.op;
|
||||||
|
const pm = scene.primitiveManager;
|
||||||
|
const mm = scene.modelManager;
|
||||||
|
const bm = scene.blockManager;
|
||||||
|
const { kind, id } = parseKey(op.key);
|
||||||
|
|
||||||
|
switch (t) {
|
||||||
|
case 'blockAdd':
|
||||||
|
bm?.addBlock?.(op.x, op.y, op.z, op.blockTypeId, op.color);
|
||||||
|
return;
|
||||||
|
case 'blockRemove':
|
||||||
|
bm?.removeBlock?.(op.x, op.y, op.z);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (t) {
|
||||||
|
case 'add': {
|
||||||
|
if (op.def?.__kind === 'primitive' && pm?.addInstance) {
|
||||||
|
// addInstance(type, opts) — opts со всеми полями (id восстановим по instanceId).
|
||||||
|
pm.addInstance(op.def.type, op.def);
|
||||||
|
} else if (op.def?.__kind === 'model' && mm?.addInstance) {
|
||||||
|
// ModelManager.addInstance(modelTypeId, x, y, z, rotationY) — позиционно.
|
||||||
|
const d = op.def;
|
||||||
|
mm.addInstance(d.modelTypeId || d.type, d.x, d.y, d.z, d.rotationY || 0);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'move': {
|
||||||
|
if (kind === 'primitive') pm?.updateInstance?.(id, { x: op.x, y: op.y, z: op.z });
|
||||||
|
else if (kind === 'model') _moveModel(mm, id, op.x, op.y, op.z);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'rotate': {
|
||||||
|
if (kind === 'primitive') {
|
||||||
|
pm?.updateInstance?.(id, { rotationX: op.rx, rotationY: op.ry, rotationZ: op.rz });
|
||||||
|
} else if (kind === 'model') {
|
||||||
|
_rotateModel(mm, id, op.ry);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'scale': {
|
||||||
|
if (kind === 'primitive') pm?.updateInstance?.(id, { sx: op.sx, sy: op.sy, sz: op.sz });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'setColor': {
|
||||||
|
if (kind === 'primitive') pm?.updateInstance?.(id, { color: op.color });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'setProp': {
|
||||||
|
if (kind === 'primitive') { const patch = {}; patch[op.prop] = op.value; pm?.updateInstance?.(id, patch); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'delete': {
|
||||||
|
if (kind === 'primitive') pm?.removeInstance?.(id);
|
||||||
|
else if (kind === 'model') mm?.removeInstance?.(id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// неизвестная операция — пропускаем (вперёд-совместимость)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Переместить модель (нет updateInstance — двигаем rootMesh напрямую). */
|
||||||
|
function _moveModel(mm, id, x, y, z) {
|
||||||
|
const inst = mm?.instances?.get?.(id);
|
||||||
|
if (inst?.rootMesh?.position) {
|
||||||
|
inst.rootMesh.position.set(x, y, z);
|
||||||
|
if (inst.data) { inst.data.x = x; inst.data.y = y; inst.data.z = z; }
|
||||||
|
inst.x = x; inst.y = y; inst.z = z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Повернуть модель вокруг Y. */
|
||||||
|
function _rotateModel(mm, id, ry) {
|
||||||
|
const inst = mm?.instances?.get?.(id);
|
||||||
|
if (inst?.rootMesh && typeof ry === 'number') {
|
||||||
|
inst.rootMesh.rotation = inst.rootMesh.rotation || { x: 0, y: 0, z: 0 };
|
||||||
|
inst.rootMesh.rotation.y = ry;
|
||||||
|
if (inst.data) inst.data.rotationY = ry;
|
||||||
|
inst.rotationY = ry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Снимок примитива для операции 'add' (с instanceId, чтобы id совпал у всех). */
|
||||||
|
function serializePrimitive(data, id, type) {
|
||||||
|
if (!data) return { __kind: 'primitive', type, instanceId: id, id };
|
||||||
|
return {
|
||||||
|
__kind: 'primitive',
|
||||||
|
type: data.type || type,
|
||||||
|
id, instanceId: id,
|
||||||
|
x: data.x, y: data.y, z: data.z,
|
||||||
|
sx: data.sx, sy: data.sy, sz: data.sz,
|
||||||
|
rotationX: data.rotationX, rotationY: data.rotationY, rotationZ: data.rotationZ,
|
||||||
|
color: data.color, material: data.material,
|
||||||
|
name: data.name, anchored: data.anchored, canCollide: data.canCollide,
|
||||||
|
visible: data.visible, mass: data.mass, studDensity: data.studDensity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Превратить patch updateInstance в конкретную операцию (move/rotate/scale/setColor/setProp). */
|
||||||
|
function patchToOp(key, patch) {
|
||||||
|
if ('x' in patch || 'y' in patch || 'z' in patch) {
|
||||||
|
return { op: 'move', key, x: patch.x, y: patch.y, z: patch.z };
|
||||||
|
}
|
||||||
|
if ('rotationX' in patch || 'rotationY' in patch || 'rotationZ' in patch) {
|
||||||
|
return { op: 'rotate', key, rx: patch.rotationX, ry: patch.rotationY, rz: patch.rotationZ };
|
||||||
|
}
|
||||||
|
if ('sx' in patch || 'sy' in patch || 'sz' in patch) {
|
||||||
|
return { op: 'scale', key, sx: patch.sx, sy: patch.sy, sz: patch.sz };
|
||||||
|
}
|
||||||
|
if ('color' in patch) {
|
||||||
|
return { op: 'setColor', key, color: patch.color };
|
||||||
|
}
|
||||||
|
// прочее — generic setProp (берём первый ключ)
|
||||||
|
const k = Object.keys(patch)[0];
|
||||||
|
return { op: 'setProp', key, prop: k, value: patch[k] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 'primitive:42' → {kind:'primitive', id:42}; 'model:m3' → {kind:'model', id:'m3'}. */
|
||||||
|
function parseKey(key) {
|
||||||
|
if (typeof key !== 'string' || !key.includes(':')) return { kind: '', id: null };
|
||||||
|
const i = key.indexOf(':');
|
||||||
|
const kind = key.slice(0, i);
|
||||||
|
let id = key.slice(i + 1);
|
||||||
|
if (kind === 'primitive') { const n = Number(id); if (Number.isFinite(n)) id = n; }
|
||||||
|
return { kind, id };
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user