studio/src/editor/engine/CollabOverlay.js
min fbf7ef680b 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>
2026-06-08 03:27:38 +03:00

181 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m]));
}