From fbf7ef680b6f788399832fe77cebd3c0de926724 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 03:27:38 +0300 Subject: [PATCH] =?UTF-8?q?feat(studio):=20Team=20Create=20=E2=80=94=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BD=D0=BE=D0=B5=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B8=D0=B3=D1=80=D1=8B=20=D0=B2=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC=20=D0=B2=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StudioCollab (Colyseus studio-room): синхрон операций примитивов/моделей/блоков, presence (курсоры/камера/выделение), soft-lock объектов, перехват менеджеров. CollabOverlay: DOM-курсоры соавторов + онлайн-аватарки + тосты. Кнопки «Скины»+«Пригласить» в TopRibbon вкладка «Игра». Гость-режим (скрыты Настройки/Сохранить/Опубликовать). Autosave только host. Вход по ?collab-токену. Co-Authored-By: Claude Opus 4.8 --- src/editor/KubikonEditor.jsx | 196 ++++++++++- src/editor/TopRibbon.jsx | 17 + src/editor/engine/CollabOverlay.js | 180 +++++++++++ src/editor/engine/StudioCollab.js | 502 +++++++++++++++++++++++++++++ 4 files changed, 886 insertions(+), 9 deletions(-) create mode 100644 src/editor/engine/CollabOverlay.js create mode 100644 src/editor/engine/StudioCollab.js diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 7a5a7ff..789eda2 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -4,12 +4,15 @@ import { jwtDecode } from 'jwt-decode'; import { useAuth } from '../auth/AuthContext.jsx'; import { useSanctions } from '../auth/SanctionsContext.jsx'; 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 { MODEL_TYPES, MODEL_CATEGORIES, getModelType } from './engine/ModelTypes'; import { getKit } from './engine/GameplayKits'; import { PRIMITIVE_TYPES, PALETTE_PRIMITIVE_TYPES } from './engine/PrimitiveTypes'; import { getModelThumbnail } from './engine/ModelThumbnails'; import * as Kubikon3DApi from '../api/Kubikon3DService'; +import { REALTIME_HTTP } from '../api/API'; import GameSettingsModal from './GameSettingsModal'; import SkinManagerModal from './SkinManagerModal'; import PublishModal from './PublishModal'; @@ -464,6 +467,15 @@ const KubikonEditor = () => { const canvasRef = 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/перед уходом // со страницы — иначе последние 600мс правок скрипта потеряются. const scriptEditorFlushRef = useRef(null); @@ -1010,6 +1022,14 @@ const KubikonEditor = () => { console.warn('[KubikonEditor] save: skip (load failed)'); 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(); if (!userId) { console.warn('[KubikonEditor] save: no userId'); @@ -1229,6 +1249,108 @@ const KubikonEditor = () => { }, AUTOSAVE_DEBOUNCE_MS); }, [doSave]); + /** + * Team Create: подключиться к комнате совместного редактирования. + * Зовётся ПОСЛЕ загрузки сцены (scene готова). projectIdNum — числовой id. + * Подключаемся если: владелец проекта (всегда) ИЛИ есть ?collab= в 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/?collab= и скопировать в буфер. + */ + 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 + загрузка проекта (если редактируем существующий) useEffect(() => { // RACE FIX: пока isLoading=true (auth ещё грузится), компонент @@ -1608,6 +1730,9 @@ const KubikonEditor = () => { } finally { console.log(`[KubikonEditor] setSceneLoading(false) — load finally (id=${id})`); setSceneLoading(false); + // Team Create: после загрузки сцены — подключиться к комнате + // совместного редактирования (владелец или по ?collab-инвайту). + if (/^\d+$/.test(id)) initCollab(Number(id)); } })(); } else { @@ -1724,13 +1849,23 @@ const KubikonEditor = () => { clearTimeout(autoSaveTimerRef.current); 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(); sceneRef.current = null; }; // isLoading в deps — без него эффект мог стрельнуть пока canvas // ещё не в DOM (isLoading=true → компонент рендерит null) и больше // не перезапускался → вечная "Загрузка проекта… 0%". - }, [isAuthenticated, isLoading, id, markDirty]); + }, [isAuthenticated, isLoading, id, markDirty, initCollab]); // beforeunload — браузерный диалог нельзя кастомизировать (API запрещает). // Auto-save срабатывает на каждое изменение через ~1.5 сек, поэтому риск @@ -1948,6 +2083,19 @@ const KubikonEditor = () => { {saveStatus === 'error' && <> Ошибка} {saveStatus === 'idle' && '—'} + {/* Гость-соавтор (приглашённый по ссылке) НЕ может менять + настройки/сохранять/публиковать — это делает только владелец. */} + {isInvitedGuest ? ( + + Совместное редактирование + + ) : ( + <> - + {/* Кнопка «Скины» переехала в TopRibbon → вкладка «Игра». */} + + )} {/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */}