Open-source web player for Rublox games, dual-licensed under AGPL-3.0 + Commercial. Highlights: - Babylon.js 7 + React 18 + Vite 5 stack - Self-contained engine (~46k lines): BlockManager, ModelManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD gamemodes - Configurable backend via VITE_API_BASE and friends — works against staging (dev-api.rublox.pro) out of the box - Standalone mode (VITE_STANDALONE=true) loads a bundled sample game for first-run without any backend - Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - Lint + format scaffolding (ESLint + Prettier + EditorConfig) - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Removed before public release: - frontend_deploy.py (contained production SSH credentials) - ~27 admin endpoints (kept in private repo) - Hard-coded internal URLs and IPs - All previous git history (clean repo init)
206 lines
7.8 KiB
JavaScript
206 lines
7.8 KiB
JavaScript
/**
|
|
* BeamManager — лучи (Beam) и следы (Trail) как объекты сцены (Фаза 5.2).
|
|
*
|
|
* Beam — светящаяся линия между двумя точками. Точки могут быть
|
|
* фиксированными координатами или ref объектов — тогда луч
|
|
* следует за объектами каждый кадр (лазеры, мосты света,
|
|
* соединения, цепи).
|
|
* Trail — шлейф, тянущийся за движущимся объектом (Babylon TrailMesh).
|
|
*
|
|
* Живут только в Play-режиме. Управляются скриптом через game.fx.* —
|
|
* каждый вызов возвращает прокси-объект.
|
|
*/
|
|
|
|
import {
|
|
MeshBuilder, StandardMaterial, Color3, Vector3,
|
|
} from '@babylonjs/core';
|
|
import { TrailMesh } from '@babylonjs/core/Meshes/trailMesh';
|
|
|
|
let _fxIdSeq = 1;
|
|
|
|
export class BeamManager {
|
|
constructor(scene3d) {
|
|
this.scene3d = scene3d;
|
|
this.scene = scene3d.scene;
|
|
/** @type {Map<number, object>} id → fx state (beam | trail) */
|
|
this.items = new Map();
|
|
this._renderHook = null;
|
|
}
|
|
|
|
start() {
|
|
if (this._renderHook) return;
|
|
this._renderHook = () => this._tick();
|
|
this.scene.registerBeforeRender(this._renderHook);
|
|
}
|
|
|
|
stop() {
|
|
if (this._renderHook) {
|
|
try { this.scene.unregisterBeforeRender(this._renderHook); } catch (e) {}
|
|
this._renderHook = null;
|
|
}
|
|
for (const it of this.items.values()) this._disposeItem(it);
|
|
this.items.clear();
|
|
}
|
|
|
|
_disposeItem(it) {
|
|
try {
|
|
if (it.mesh) it.mesh.dispose();
|
|
if (it.mat) it.mat.dispose();
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
/**
|
|
* Создать луч между двумя точками.
|
|
* opts: { from, to — {x,y,z} или ref-строка объекта;
|
|
* color: '#hex', width: толщина (м) }.
|
|
* Возвращает id.
|
|
*/
|
|
addBeam(opts = {}) {
|
|
const id = _fxIdSeq++;
|
|
const width = Number.isFinite(opts.width) ? opts.width : 0.15;
|
|
const mat = new StandardMaterial('beamMat_' + id, this.scene);
|
|
const col = Color3.FromHexString(opts.color || '#66ccff');
|
|
mat.diffuseColor = col;
|
|
mat.emissiveColor = col;
|
|
mat.disableLighting = true;
|
|
// Цилиндр-заготовка единичной высоты — масштабируем под длину луча.
|
|
const mesh = MeshBuilder.CreateCylinder('beam_' + id,
|
|
{ height: 1, diameter: width, tessellation: 8 }, this.scene);
|
|
mesh.material = mat;
|
|
mesh.isPickable = false;
|
|
mesh.renderingGroupId = 1;
|
|
const it = {
|
|
id, type: 'beam', mesh, mat,
|
|
from: opts.from, to: opts.to,
|
|
};
|
|
this.items.set(id, it);
|
|
this._updateBeam(it); // сразу позиционируем
|
|
return id;
|
|
}
|
|
|
|
/** Сменить цвет луча. */
|
|
setBeamColor(id, color) {
|
|
const it = this.items.get(Number(id));
|
|
if (!it || it.type !== 'beam' || !it.mat) return;
|
|
const col = Color3.FromHexString(color || '#66ccff');
|
|
it.mat.diffuseColor = col;
|
|
it.mat.emissiveColor = col;
|
|
}
|
|
|
|
/** Сменить концы луча (координаты или ref). */
|
|
setBeamEndpoints(id, from, to) {
|
|
const it = this.items.get(Number(id));
|
|
if (!it || it.type !== 'beam') return;
|
|
if (from !== undefined) it.from = from;
|
|
if (to !== undefined) it.to = to;
|
|
}
|
|
|
|
/**
|
|
* Создать шлейф за объектом.
|
|
* ref — ref-строка объекта. opts: { color, width, lifetime (сек) }.
|
|
* Возвращает id.
|
|
*/
|
|
addTrail(ref, opts = {}) {
|
|
const data = this._resolve(ref);
|
|
const mesh = data && (data.mesh || data.rootMesh || data.rootNode);
|
|
if (!mesh) return null;
|
|
const id = _fxIdSeq++;
|
|
const width = Number.isFinite(opts.width) ? opts.width : 0.4;
|
|
const lifetime = Number.isFinite(opts.lifetime) ? opts.lifetime : 1.5;
|
|
// TrailMesh(name, generator, scene, diameter, length, autoStart).
|
|
// length — сколько сегментов хранить; считаем из lifetime (≈60 fps).
|
|
const segments = Math.max(10, Math.round(lifetime * 60));
|
|
const trail = new TrailMesh('trail_' + id, mesh, this.scene,
|
|
width, segments, true);
|
|
const mat = new StandardMaterial('trailMat_' + id, this.scene);
|
|
const col = Color3.FromHexString(opts.color || '#ffcc44');
|
|
mat.diffuseColor = col;
|
|
mat.emissiveColor = col;
|
|
mat.disableLighting = true;
|
|
mat.alpha = 0.7;
|
|
trail.material = mat;
|
|
trail.isPickable = false;
|
|
trail.renderingGroupId = 1;
|
|
const it = { id, type: 'trail', mesh: trail, mat };
|
|
this.items.set(id, it);
|
|
return id;
|
|
}
|
|
|
|
/** Убрать луч/след по id. */
|
|
remove(id) {
|
|
const it = this.items.get(Number(id));
|
|
if (!it) return;
|
|
this._disposeItem(it);
|
|
this.items.delete(it.id);
|
|
}
|
|
|
|
// ===== внутреннее =====
|
|
|
|
_tick() {
|
|
if (this.items.size === 0) return;
|
|
for (const it of this.items.values()) {
|
|
// Trail обновляется самим Babylon (autoStart). Beam — мы.
|
|
if (it.type === 'beam') this._updateBeam(it);
|
|
}
|
|
}
|
|
|
|
_updateBeam(it) {
|
|
const a = this._point(it.from);
|
|
const b = this._point(it.to);
|
|
if (!a || !b || !it.mesh) return;
|
|
const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
|
|
const len = Math.hypot(dx, dy, dz);
|
|
if (len < 0.001) { it.mesh.setEnabled(false); return; }
|
|
it.mesh.setEnabled(true);
|
|
// Цилиндр единичной высоты вдоль локальной оси Y. Растягиваем по длине.
|
|
it.mesh.scaling.y = len;
|
|
// Центр луча.
|
|
it.mesh.position.set((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2);
|
|
// Ориентируем ось Y цилиндра вдоль вектора a→b.
|
|
const dir = new Vector3(dx, dy, dz).normalize();
|
|
// yaw + pitch так, чтобы +Y смотрел вдоль dir.
|
|
const yaw = Math.atan2(dir.x, dir.z);
|
|
const pitch = Math.acos(Math.max(-1, Math.min(1, dir.y)));
|
|
it.mesh.rotation.set(pitch, yaw, 0);
|
|
}
|
|
|
|
/** Точка из {x,y,z} или ref-строки объекта. */
|
|
_point(p) {
|
|
if (!p) return null;
|
|
if (typeof p === 'object' && Number.isFinite(p.x)) {
|
|
return { x: p.x, y: p.y, z: p.z };
|
|
}
|
|
if (typeof p === 'string') {
|
|
const d = this._resolve(p);
|
|
if (d) return { x: d.x, y: d.y, z: d.z };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Резолв ref в data-объект менеджера. */
|
|
_resolve(ref) {
|
|
if (typeof ref !== 'string') return null;
|
|
const rt = this.scene3d.gameRuntime;
|
|
let r = ref;
|
|
if (rt && rt._localToReal && rt._localToReal.has(r)) {
|
|
r = rt._localToReal.get(r);
|
|
}
|
|
const colon = r.indexOf(':');
|
|
if (colon < 0) return null;
|
|
const kind = r.slice(0, colon);
|
|
const rest = r.slice(colon + 1);
|
|
const getFrom = (mgr) => {
|
|
if (!mgr || !mgr.instances) return null;
|
|
let d = mgr.instances.get(rest);
|
|
if (!d) {
|
|
const n = Number(rest);
|
|
if (Number.isFinite(n)) d = mgr.instances.get(n);
|
|
}
|
|
return d || null;
|
|
};
|
|
if (kind === 'primitive') return getFrom(this.scene3d.primitiveManager);
|
|
if (kind === 'model') return getFrom(this.scene3d.modelManager);
|
|
return null;
|
|
}
|
|
}
|