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)
165 lines
6.5 KiB
JavaScript
165 lines
6.5 KiB
JavaScript
/**
|
||
* GdPlayerTrail — частицы-шлейф за кубом игрока (этап G8).
|
||
*
|
||
* 3 типа trail соответствуют скинам из магазина (cubeSkinFactories.CUBE_TRAILS):
|
||
* - trail_white — белые точки, мягкое мерцание
|
||
* - trail_fire — оранжево-жёлтые искры с гравитацией
|
||
* - trail_ice — синие звёздочки, медленно падают
|
||
*
|
||
* Тип берётся из gd_progress.equipped_trail. При смене trail в магазине
|
||
* нужно пересоздать (мы пересоздаём при следующем enterPlayMode).
|
||
*/
|
||
import {
|
||
ParticleSystem, Color4, Color3, Vector3, MeshBuilder, StandardMaterial,
|
||
DynamicTexture, Texture,
|
||
} from '@babylonjs/core';
|
||
|
||
const PLAYER_PRIM_ID = 10001;
|
||
const STORYS_BASE = '/api-storys';
|
||
|
||
export class GdPlayerTrail {
|
||
constructor() {
|
||
this.scene = null;
|
||
this._scene3d = null;
|
||
this._cubeMesh = null;
|
||
this._emitter = null;
|
||
this._particles = null;
|
||
this._textureRefs = [];
|
||
}
|
||
|
||
/** projectId нужен чтобы прочитать gd_progress.equipped_trail. */
|
||
async attach(scene, scene3d, projectId, userId) {
|
||
if (!scene || !scene3d) return;
|
||
this.scene = scene;
|
||
this._scene3d = scene3d;
|
||
// Прочитать выбранный trail
|
||
let trailId = 'trail_white';
|
||
try {
|
||
const url = `${STORYS_BASE}/kubikon3d/savegame/${projectId}/${userId}/gd_progress`;
|
||
const r = await fetch(url, {
|
||
headers: { Authorization: localStorage.getItem('Authorization') || '' },
|
||
});
|
||
if (r.ok) {
|
||
const j = await r.json();
|
||
trailId = j?.data?.equipped_trail || 'trail_white';
|
||
}
|
||
} catch (e) { /* fallback на trail_white */ }
|
||
// Ждём куб игрока (он создаётся в primitiveManager при load)
|
||
let attempts = 0;
|
||
const tryAttach = () => {
|
||
attempts++;
|
||
const pm = scene3d.primitiveManager;
|
||
const data = pm?.instances?.get(PLAYER_PRIM_ID);
|
||
const mesh = data?.mesh;
|
||
if (!mesh) {
|
||
if (attempts < 10) setTimeout(tryAttach, 200);
|
||
return;
|
||
}
|
||
this._cubeMesh = mesh;
|
||
this._setupTrail(trailId);
|
||
console.log(`[GdPlayerTrail] trail=${trailId}, attached`);
|
||
};
|
||
tryAttach();
|
||
}
|
||
|
||
_setupTrail(trailId) {
|
||
const ps = new ParticleSystem('gd_trail', 200, this.scene);
|
||
ps.particleTexture = this._makeTexture(trailId);
|
||
ps.emitter = this._cubeMesh; // emitter — сам куб, частицы рождаются вокруг него
|
||
ps.minEmitBox = new Vector3(-0.3, -0.3, -0.3);
|
||
ps.maxEmitBox = new Vector3(0.3, 0.3, 0.3);
|
||
ps.minSize = 0.15;
|
||
ps.maxSize = 0.35;
|
||
ps.minLifeTime = 0.25;
|
||
ps.maxLifeTime = 0.6;
|
||
ps.emitRate = 60;
|
||
ps.minAngularSpeed = -2;
|
||
ps.maxAngularSpeed = 2;
|
||
ps.updateSpeed = 0.02;
|
||
|
||
if (trailId === 'trail_fire') {
|
||
ps.color1 = new Color4(1.0, 0.7, 0.1, 1);
|
||
ps.color2 = new Color4(1.0, 0.3, 0.0, 1);
|
||
ps.colorDead = new Color4(0.2, 0.0, 0.0, 0);
|
||
ps.minSize = 0.20; ps.maxSize = 0.45;
|
||
ps.minLifeTime = 0.30; ps.maxLifeTime = 0.7;
|
||
ps.gravity = new Vector3(0, -3, 0);
|
||
ps.direction1 = new Vector3(-1, -1, -1);
|
||
ps.direction2 = new Vector3(1, 0, 1);
|
||
ps.blendMode = ParticleSystem.BLENDMODE_ADD;
|
||
ps.emitRate = 80;
|
||
} else if (trailId === 'trail_ice') {
|
||
ps.color1 = new Color4(0.55, 0.85, 1.0, 1);
|
||
ps.color2 = new Color4(0.85, 0.95, 1.0, 1);
|
||
ps.colorDead = new Color4(0.7, 0.85, 1.0, 0);
|
||
ps.minSize = 0.18; ps.maxSize = 0.35;
|
||
ps.gravity = new Vector3(0, -2, 0);
|
||
ps.direction1 = new Vector3(-0.5, -0.5, -0.5);
|
||
ps.direction2 = new Vector3(0.5, 0.2, 0.5);
|
||
ps.blendMode = ParticleSystem.BLENDMODE_STANDARD;
|
||
} else {
|
||
// trail_white — мягкие белые точки
|
||
ps.color1 = new Color4(1, 1, 1, 0.95);
|
||
ps.color2 = new Color4(0.95, 0.95, 1.0, 0.85);
|
||
ps.colorDead = new Color4(1, 1, 1, 0);
|
||
ps.gravity = new Vector3(0, 0, 0);
|
||
ps.direction1 = new Vector3(-0.3, -0.3, -0.3);
|
||
ps.direction2 = new Vector3(0.3, 0.3, 0.3);
|
||
ps.blendMode = ParticleSystem.BLENDMODE_STANDARD;
|
||
}
|
||
ps.minEmitPower = 0.3;
|
||
ps.maxEmitPower = 1.0;
|
||
ps.start();
|
||
this._particles = ps;
|
||
}
|
||
|
||
/** Текстура частицы — круг с белым/цветным мягким градиентом. */
|
||
_makeTexture(trailId) {
|
||
const S = 64;
|
||
const dt = new DynamicTexture(`gd_trail_${trailId}_tex`, { width: S, height: S }, this.scene, true);
|
||
const ctx = dt.getContext();
|
||
ctx.clearRect(0, 0, S, S);
|
||
const cx = S / 2, cy = S / 2;
|
||
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, S / 2);
|
||
if (trailId === 'trail_ice') {
|
||
// Снежинка-звёздочка
|
||
ctx.strokeStyle = '#cce4ff';
|
||
ctx.lineWidth = 3;
|
||
ctx.lineCap = 'round';
|
||
for (let i = 0; i < 6; i++) {
|
||
const a = (i / 6) * Math.PI * 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, cy);
|
||
ctx.lineTo(cx + Math.cos(a) * (S / 2 - 4), cy + Math.sin(a) * (S / 2 - 4));
|
||
ctx.stroke();
|
||
}
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
} else {
|
||
g.addColorStop(0, 'rgba(255,255,255,1)');
|
||
g.addColorStop(0.5, 'rgba(255,255,255,0.6)');
|
||
g.addColorStop(1, 'rgba(255,255,255,0)');
|
||
ctx.fillStyle = g;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, S / 2, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
dt.hasAlpha = true;
|
||
dt.update();
|
||
this._textureRefs.push(dt);
|
||
return dt;
|
||
}
|
||
|
||
dispose() {
|
||
if (this._particles) try { this._particles.dispose(); } catch (e) {}
|
||
for (const t of this._textureRefs) try { t.dispose(); } catch (e) {}
|
||
this._textureRefs = [];
|
||
this._particles = null;
|
||
this._cubeMesh = null;
|
||
this._scene3d = null;
|
||
this.scene = null;
|
||
}
|
||
}
|