StudioCollab (Colyseus studio-room): синхрон операций примитивов/моделей/блоков, presence (курсоры/камера/выделение), soft-lock объектов, перехват менеджеров. CollabOverlay: DOM-курсоры соавторов + онлайн-аватарки + тосты. Кнопки «Скины»+«Пригласить» в TopRibbon вкладка «Игра». Гость-режим (скрыты Настройки/Сохранить/Опубликовать). Autosave только host. Вход по ?collab-токену. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
181 lines
8.7 KiB
JavaScript
181 lines
8.7 KiB
JavaScript
/**
|
||
* 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]));
|
||
}
|