studio/src/editor/engine/AchievementsManager.js
min 33cd435d06 feat(studio): прогресс лидербордов и достижений сохраняется в БД (между сессиями)
GameRuntime.saveProgress/loadProgress — helper к storys savegame endpoint
(/kubikon3d/savegame/<pid>/<uid>/<ns>) для движковых менеджеров.

- Достижения: при unlock сохраняются в БД (namespace _achievements) + localStorage
  как быстрый кэш; loadFromDB при Play восстанавливает разблокированные.
- Лидерстаты: статы текущего игрока сохраняются в БД (namespace _leaderstats,
  дебаунс 1с при set); loadFromDB при Play восстанавливает значения.
- Загрузка из БД через 250мс после старта скриптов (даём define зарегистрировать).

Теперь прогресс игрока подгружается при каждой сессии с любого устройства.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 09:59:11 +03:00

250 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 */ }
}
/** Загрузить разблокированные достижения из БД (по игроку). Вызывать при Play. */
loadFromDB() {
const rt = this.s?.gameRuntime;
if (!rt || !rt.loadProgress) return;
rt.loadProgress('_achievements', (data) => {
if (Array.isArray(data)) {
for (const id of data) this._unlocked.add(id);
}
});
}
_persist() {
// 1) локально (быстрый кэш), 2) в БД (между сессиями, любое устройство).
try { localStorage.setItem(this._projectKey, JSON.stringify([...this._unlocked])); } catch (e) {}
try { this.s?.gameRuntime?.saveProgress?.('_achievements', [...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 =
'<div style="font-size:42px;flex:0 0 auto;filter:drop-shadow(0 2px 4px rgba(0,0,0,0.4))">' + def.icon + '</div>' +
'<div style="flex:1;min-width:0">' +
'<div style="font-size:11px;opacity:0.85;font-weight:700;text-transform:uppercase;letter-spacing:0.5px">Достижение разблокировано · ' + r.label + '</div>' +
'<div style="font-size:17px;font-weight:800;margin:1px 0">' + this._esc(def.name) + '</div>' +
'<div style="font-size:12px;opacity:0.9">' + this._esc(def.description) + ' · +' + def.points + ' очк.</div>' +
'</div>';
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 = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
'<div style="font-size:22px;font-weight:800">🏆 Мои достижения</div>' +
'<button id="_achClose" style="width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);color:#fff;font-size:18px;cursor:pointer">✕</button></div>';
html += '<div style="font-size:14px;color:#9aa3b2;margin-bottom:6px">' + pr.unlocked + ' из ' + pr.total + ' (' + pr.points + ' / ' + pr.maxPoints + ' очков)</div>';
html += '<div style="height:8px;background:rgba(255,255,255,0.1);border-radius:6px;margin-bottom:18px;overflow:hidden"><div style="height:100%;width:' + pct + '%;background:linear-gradient(90deg,#ffd23a,#ff9a3a);border-radius:6px"></div></div>';
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px">';
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 += '<div style="background:rgba(255,255,255,0.04);border:2px solid ' + (un ? r.border : 'rgba(255,255,255,0.08)') + ';border-radius:14px;padding:14px 10px;text-align:center;' + (un ? '' : 'opacity:0.55;') + '">' +
'<div style="font-size:44px;margin-bottom:6px;' + (un ? '' : 'filter:grayscale(1);') + '">' + icon + (un ? '' : ' 🔒') + '</div>' +
'<div style="font-size:14px;font-weight:800">' + this._esc(name) + '</div>' +
'<div style="font-size:11px;color:#9aa3b2;margin-top:3px;line-height:1.3">' + this._esc(desc) + '</div>' +
'<div style="font-size:10px;font-weight:700;margin-top:6px;color:' + r.border + '">' + r.label + ' · ' + d.points + ' очк.</div>' +
'</div>';
}
html += '</div>';
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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;
}
}