/** * AchievementsManager — достижения (badges) как в Roblox (задача 20). * * - define([...]) регистрирует достижения проекта. * - unlock(id) разблокирует → toast справа-сверху (4 редкости, очередь, звук). * - bindToStat(id, statName, {gte/lte/eq}) — авто-unlock по leaderstat. * - кнопка-кубок слева-снизу → страница «Мои достижения» (grid + прогресс). * - сохранение разблокированных в localStorage по projectId (закрыл-открыл → остались). * * API (через game.achievements.*): define/unlock/has/list/progress/bindToStat/ * setButtonVisible/openPage. * * Фича-парность: тот же модуль в rublox-player/src/engine/. */ const RARITY = { common: { label: 'Обычное', border: '#9aa3b2', bg: 'linear-gradient(135deg,rgba(120,130,150,0.9),rgba(80,88,104,0.9))', glow: 'rgba(154,163,178,0.5)' }, rare: { label: 'Редкое', border: '#4d8bff', bg: 'linear-gradient(135deg,rgba(60,110,220,0.92),rgba(30,60,150,0.92))', glow: 'rgba(77,139,255,0.6)' }, epic: { label: 'Эпическое', border: '#a05aff', bg: 'linear-gradient(135deg,rgba(150,80,230,0.92),rgba(90,40,160,0.92))', glow: 'rgba(160,90,255,0.65)' }, legendary: { label: 'Легендарное', border: '#ffd23a', bg: 'linear-gradient(135deg,rgba(255,200,60,0.95),rgba(220,140,20,0.95))', glow: 'rgba(255,210,58,0.75)' }, }; export class AchievementsManager { constructor(scene3d) { this.s = scene3d; this._defs = []; // [{id,name,description,icon,rarity,points,hidden}] this._unlocked = new Set(); // id разблокированных this._binds = []; // [{id, stat, op, value}] this._toastQueue = []; this._toastActive = false; this._btnVisible = true; this.btn = null; this.toastRoot = null; this.page = null; this._projectKey = 'rublox_ach_' + (this.s?._projectId ?? 'proj'); } define(list) { const arr = Array.isArray(list) ? list : [list]; for (const a of arr) { if (!a || typeof a.id !== 'string') continue; if (this._defs.some(d => d.id === a.id)) continue; this._defs.push({ id: a.id, name: a.name || a.id, description: a.description || '', icon: a.icon || '🏆', rarity: RARITY[a.rarity] ? a.rarity : 'common', points: Number(a.points) || 5, hidden: !!a.hidden, }); } this._loadSaved(); this._mountButton(); } _loadSaved() { try { const raw = localStorage.getItem(this._projectKey); if (raw) for (const id of JSON.parse(raw)) this._unlocked.add(id); } catch (e) { /* ignore */ } } _persist() { try { localStorage.setItem(this._projectKey, JSON.stringify([...this._unlocked])); } catch (e) {} } unlock(id, _playerId) { const def = this._defs.find(d => d.id === id); if (!def || this._unlocked.has(id)) return false; this._unlocked.add(id); this._persist(); this._queueToast(def); this._playSound(def.rarity); return true; } has(id) { return this._unlocked.has(id); } list() { return this._defs.map(d => ({ id: d.id, name: d.name, unlocked: this._unlocked.has(d.id) })); } progress() { const total = this._defs.length; const unlocked = this._defs.filter(d => this._unlocked.has(d.id)).length; const pts = this._defs.filter(d => this._unlocked.has(d.id)).reduce((s, d) => s + d.points, 0); const maxPts = this._defs.reduce((s, d) => s + d.points, 0); return { total, unlocked, points: pts, maxPoints: maxPts }; } /** Авто-unlock при достижении leaderstat значения. */ bindToStat(id, statName, cond) { const op = cond && (cond.gte != null ? 'gte' : cond.lte != null ? 'lte' : cond.eq != null ? 'eq' : null); if (!op) return; this._binds.push({ id, stat: statName, op, value: cond[op] }); // Подпишемся на leaderstats при первом bind. if (!this._boundLs && this.s?.leaderstats) { this._boundLs = true; this.s.leaderstats.onChange((pid, name, nv) => this._checkBinds(name, nv)); } } _checkBinds(statName, value) { for (const b of this._binds) { if (b.stat !== statName || this._unlocked.has(b.id)) continue; const ok = b.op === 'gte' ? value >= b.value : b.op === 'lte' ? value <= b.value : value === b.value; if (ok) this.unlock(b.id); } } setButtonVisible(v) { this._btnVisible = !!v; if (this.btn) this.btn.style.display = v ? 'flex' : 'none'; } get active() { return this._defs.length > 0; } // ── Кнопка-кубок ─────────────────────────────────────────────────────── _mountButton() { if (this.btn || !this.active) return; if (!this.s?._isPlaying) return; // кнопка-кубок только в Play const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body; const b = document.createElement('button'); b.title = 'Мои достижения'; b.textContent = '🏆'; b.style.cssText = [ 'position:absolute', 'left:14px', 'bottom:64px', 'z-index:50', 'width:46px', 'height:46px', 'border-radius:12px', 'font-size:24px', 'background:rgba(18,22,33,0.6)', 'backdrop-filter:blur(8px)', 'border:1px solid rgba(255,255,255,0.15)', 'cursor:pointer', 'display:flex', 'align-items:center', 'justify-content:center', 'box-shadow:0 4px 16px rgba(0,0,0,0.35)', 'pointer-events:auto', ].join(';'); if (!this._btnVisible) b.style.display = 'none'; b.onclick = () => this.openPage(); parent.appendChild(b); this.btn = b; } // ── Toast ──────────────────────────────────────────────────────────── _queueToast(def) { this._toastQueue.push(def); if (!this._toastActive) this._nextToast(); } _nextToast() { if (!this._toastQueue.length) { this._toastActive = false; return; } this._toastActive = true; const def = this._toastQueue.shift(); const r = RARITY[def.rarity]; const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body; const t = document.createElement('div'); t.style.cssText = [ 'position:absolute', 'top:200px', 'right:14px', 'z-index:60', 'width:340px', 'display:flex', 'align-items:center', 'gap:12px', 'padding:12px 14px', 'border-radius:14px', 'background:' + r.bg, 'border:2px solid ' + r.border, 'box-shadow:0 0 24px ' + r.glow + ',0 8px 24px rgba(0,0,0,0.4)', 'font-family:Inter,system-ui,sans-serif', 'color:#fff', 'transform:translateX(380px)', 'transition:transform .32s cubic-bezier(.2,.8,.3,1)', 'pointer-events:auto', 'cursor:pointer', ].join(';'); t.innerHTML = '
' + def.icon + '
' + '
' + '
Достижение разблокировано · ' + r.label + '
' + '
' + this._esc(def.name) + '
' + '
' + this._esc(def.description) + ' · +' + def.points + ' очк.
' + '
'; t.onclick = () => this.openPage(); parent.appendChild(t); // slide-in requestAnimationFrame(() => { t.style.transform = 'translateX(0)'; }); // через 3с slide-out + следующий setTimeout(() => { t.style.transform = 'translateX(380px)'; setTimeout(() => { try { t.remove(); } catch (e) {} this._nextToast(); }, 350); }, 3000); } _playSound(rarity) { // Используем встроенные звуки движка через gameRuntime/audio. try { const map = { common: 'coin', rare: 'win', epic: 'win', legendary: 'win' }; const pitch = { common: 1, rare: 1.1, epic: 0.9, legendary: 0.8 }[rarity] || 1; this.s?.gameRuntime?._playSound?.({ name: map[rarity] || 'coin', pitch }); } catch (e) { /* ignore */ } } // ── Страница «Мои достижения» ─────────────────────────────────────────── openPage() { if (this.page) { this._closePage(); return; } const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body; const overlay = document.createElement('div'); overlay.style.cssText = [ 'position:absolute', 'inset:0', 'z-index:80', 'background:rgba(8,10,16,0.78)', 'backdrop-filter:blur(6px)', 'display:flex', 'align-items:center', 'justify-content:center', 'font-family:Inter,system-ui,sans-serif', 'pointer-events:auto', ].join(';'); overlay.onclick = (e) => { if (e.target === overlay) this._closePage(); }; const pr = this.progress(); const pct = pr.total ? Math.round(pr.unlocked / pr.total * 100) : 0; const panel = document.createElement('div'); panel.style.cssText = 'width:min(720px,92%);max-height:84%;overflow-y:auto;background:#141826;border:1px solid rgba(255,255,255,0.12);border-radius:18px;padding:22px;color:#e8ecf2;box-shadow:0 20px 60px rgba(0,0,0,0.5)'; let html = '
' + '
🏆 Мои достижения
' + '
'; html += '
' + pr.unlocked + ' из ' + pr.total + ' (' + pr.points + ' / ' + pr.maxPoints + ' очков)
'; html += '
'; html += '
'; for (const d of this._defs) { const un = this._unlocked.has(d.id); const r = RARITY[d.rarity]; const hiddenLocked = d.hidden && !un; const icon = hiddenLocked ? '❔' : d.icon; const name = hiddenLocked ? 'Скрытое достижение' : d.name; const desc = hiddenLocked ? 'Найди, чтобы открыть' : d.description; html += '
' + '
' + icon + (un ? '' : ' 🔒') + '
' + '
' + this._esc(name) + '
' + '
' + this._esc(desc) + '
' + '
' + r.label + ' · ' + d.points + ' очк.
' + '
'; } html += '
'; panel.innerHTML = html; overlay.appendChild(panel); parent.appendChild(overlay); panel.querySelector('#_achClose').onclick = () => this._closePage(); this.page = overlay; } _closePage() { if (this.page) { try { this.page.remove(); } catch (e) {} this.page = null; } } _esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); } serialize() { return this._defs.map(d => ({ ...d })); } load(arr) { if (Array.isArray(arr) && arr.length) this.define(arr); } dispose() { for (const el of [this.btn, this.toastRoot, this.page]) { if (el) try { el.remove(); } catch (e) {} } this.btn = null; this.page = null; this._toastQueue = []; this._toastActive = false; } resetRuntime() { // Определения и unlocked сохраняются (достижения «навсегда»). Чистим UI. this._closePage(); this._toastQueue = []; this._toastActive = false; } }