/** * 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; } }