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)
170 lines
7.1 KiB
JavaScript
170 lines
7.1 KiB
JavaScript
/**
|
||
* GdFinish — финишные ворота + конфетти при пересечении (этап G5).
|
||
*
|
||
* Ставит выбранную фабрику ворот на FINISH_X (из cylinder-примитива или из
|
||
* самого правого ненулевого x уровня). Поворачивает поперёк трассы (Y=90°).
|
||
*
|
||
* При вызове .celebrate() запускает конфетти-партикль на 1.5с.
|
||
*
|
||
* Финиш-событие триггерится в скрипте уровня (по FINISH_X) — мы лишь рисуем
|
||
* ворота. Подключим celebrate из BabylonScene при gdFinish сообщении.
|
||
*/
|
||
import {
|
||
Vector3, TransformNode, ParticleSystem, Texture, DynamicTexture,
|
||
Color3, Color4, MeshBuilder, StandardMaterial,
|
||
} from '@babylonjs/core';
|
||
import { FINISH_CATALOG } from '../AdminPreview/gdFinishes/finishFactories';
|
||
|
||
const DEFAULT_FINISH_BY_EPOCH = {
|
||
1: 'f1_v4', // Рустик (выбор юзера)
|
||
2: 'f2_v5', // Бронзовая
|
||
3: 'f3_v3',
|
||
4: 'f4_v4',
|
||
5: 'f5_v2',
|
||
6: 'f6_v4',
|
||
7: 'f7_v1',
|
||
8: 'f8_v5',
|
||
9: 'f9_v4',
|
||
10: 'f10_v5',
|
||
};
|
||
|
||
export class GdFinish {
|
||
constructor() {
|
||
this.scene = null;
|
||
this._scene3d = null;
|
||
this._handle = null;
|
||
this._root = null;
|
||
this._particles = null;
|
||
this._particleEmitter = null;
|
||
this._celebrated = false;
|
||
}
|
||
|
||
attach(scene, scene3d, epoch = 1, finishId = null) {
|
||
if (!scene || !scene3d) return;
|
||
this.scene = scene;
|
||
this._scene3d = scene3d;
|
||
const id = finishId || DEFAULT_FINISH_BY_EPOCH[epoch] || 'f1_v1';
|
||
const factory = FINISH_CATALOG.find(f => f.id === id);
|
||
if (!factory) {
|
||
console.warn('[GdFinish] фабрика не найдена:', id);
|
||
return;
|
||
}
|
||
// Определяем FINISH_X — ищем cylinder-примитив (это стандартный gd_finish)
|
||
let finishX = null;
|
||
const pm = scene3d.primitiveManager;
|
||
if (pm) {
|
||
for (const data of pm.instances.values()) {
|
||
if (String(data.type) === 'cylinder' && typeof data.x === 'number' && data.x > 50) {
|
||
// самый правый cylinder = финиш
|
||
if (finishX == null || data.x > finishX) finishX = data.x;
|
||
}
|
||
}
|
||
}
|
||
if (finishX == null) {
|
||
console.warn('[GdFinish] не нашли cylinder-финиш, ставлю на x=1000');
|
||
finishX = 1000;
|
||
}
|
||
// Скрываем оригинальный cylinder если он рядом с финишем
|
||
if (pm) {
|
||
for (const data of pm.instances.values()) {
|
||
if (String(data.type) === 'cylinder' && Math.abs(data.x - finishX) < 2 && data.mesh) {
|
||
try { data.mesh.setEnabled(false); } catch (e) {}
|
||
this._hiddenFinishCylinder = data.mesh;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
const h = factory.make(scene, `gd_finish_inst`);
|
||
if (!h || !h.root) return;
|
||
this._handle = h;
|
||
this._root = h.root;
|
||
this._root.position = new Vector3(finishX, 1, 0);
|
||
this._root.rotation.y = Math.PI / 2; // поперёк трассы
|
||
this._finishX = finishX;
|
||
this._setupConfetti(finishX);
|
||
// Автоматически срабатывает когда куб игрока пересекает FINISH_X.
|
||
// Скрипт уровня сам показывает win-screen, мы только конфетти добавим.
|
||
this._onBeforeRender = () => {
|
||
if (this._celebrated) return;
|
||
const pp = this._scene3d?.player?._pos;
|
||
if (!pp) return;
|
||
if (pp.x >= this._finishX - 1.5) {
|
||
this.celebrate();
|
||
}
|
||
};
|
||
this.scene.onBeforeRenderObservable.add(this._onBeforeRender);
|
||
}
|
||
|
||
/** Создать ParticleSystem конфетти (старт по celebrate). */
|
||
_setupConfetti(finishX) {
|
||
// Маленький emitter-mesh — невидимая точка над финишем
|
||
const emitter = MeshBuilder.CreateBox('gd_confetti_emitter', { size: 0.1 }, this.scene);
|
||
emitter.position.set(finishX, 4, 0);
|
||
emitter.isVisible = false;
|
||
this._particleEmitter = emitter;
|
||
|
||
// Текстура частицы — квадратик canvas (просто белый круг)
|
||
const T = 32;
|
||
const dt = new DynamicTexture('gd_confetti_tex', { width: T, height: T }, this.scene, true);
|
||
const ctx = dt.getContext();
|
||
ctx.clearRect(0, 0, T, T);
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.beginPath();
|
||
ctx.arc(T / 2, T / 2, T / 2 - 2, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
dt.hasAlpha = true;
|
||
dt.update();
|
||
|
||
const ps = new ParticleSystem('gd_confetti', 400, this.scene);
|
||
ps.particleTexture = dt;
|
||
ps.emitter = emitter;
|
||
ps.minEmitBox = new Vector3(-1.5, 0, -1.5);
|
||
ps.maxEmitBox = new Vector3(1.5, 0, 1.5);
|
||
// Конфетти разных цветов
|
||
ps.color1 = new Color4(1, 0.3, 0.4, 1);
|
||
ps.color2 = new Color4(0.3, 0.7, 1, 1);
|
||
ps.colorDead = new Color4(1, 1, 1, 0);
|
||
ps.minSize = 0.15; ps.maxSize = 0.35;
|
||
ps.minLifeTime = 1.0; ps.maxLifeTime = 2.5;
|
||
ps.emitRate = 0; // пока выключен
|
||
ps.gravity = new Vector3(0, -8, 0);
|
||
ps.direction1 = new Vector3(-3, 5, -3);
|
||
ps.direction2 = new Vector3(3, 8, 3);
|
||
ps.minAngularSpeed = -3; ps.maxAngularSpeed = 3;
|
||
ps.minEmitPower = 1; ps.maxEmitPower = 3;
|
||
ps.updateSpeed = 0.02;
|
||
ps.start(); // активен но без эмиссии
|
||
this._particles = ps;
|
||
}
|
||
|
||
/** Включить конфетти на ~1.5 секунды. Вызывает уровень при пересечении финиша. */
|
||
celebrate() {
|
||
if (this._celebrated || !this._particles) return;
|
||
this._celebrated = true;
|
||
this._particles.emitRate = 250;
|
||
setTimeout(() => {
|
||
try { this._particles.emitRate = 0; } catch (e) {}
|
||
}, 1500);
|
||
}
|
||
|
||
dispose() {
|
||
try {
|
||
if (this._onBeforeRender && this.scene) {
|
||
this.scene.onBeforeRenderObservable.removeCallback(this._onBeforeRender);
|
||
this._onBeforeRender = null;
|
||
}
|
||
} catch (e) {}
|
||
if (this._handle && this._handle.dispose) try { this._handle.dispose(); } catch (e) {}
|
||
if (this._particles) try { this._particles.dispose(); } catch (e) {}
|
||
if (this._particleEmitter) try { this._particleEmitter.dispose(); } catch (e) {}
|
||
if (this._hiddenFinishCylinder) try { this._hiddenFinishCylinder.setEnabled(true); } catch (e) {}
|
||
this._handle = null;
|
||
this._root = null;
|
||
this._particles = null;
|
||
this._particleEmitter = null;
|
||
this._hiddenFinishCylinder = null;
|
||
this.scene = null;
|
||
this._scene3d = null;
|
||
}
|
||
}
|