studio/src/editor/engine/AchievementsManager.js
min 5d49cd9eeb feat(studio): задача 20 — лидерборды (leaderstats) + достижения (achievements)
Leaderstats: HUD-таблица top-right (blur, сортировка по primary, топ-10,
подсветка me, flash-инкремент). API game.leaderstats.define/set/add/get/
onChange + me.* (format number/time/short). LeaderstatsManager.js.

Достижения: toast справа (4 редкости + звук + очередь, slide-in/out), кнопка-
кубок слева-снизу → страница grid (locked grayscale+замок, hidden=?, прогресс-
бар). API game.achievements.define/unlock/has/bindToStat/setButtonVisible/
openPage. bindToStat(id, stat, {gte/lte/eq}) — авто-unlock по лидерстату.
Сохранение unlocked в localStorage по проекту. AchievementsManager.js.

Интеграция: оба менеджера в BabylonScene (tick leaderstats в Play, resetRuntime
при stop, serialize/load в project_data scene.leaderstats/achievements). worker-
API + GameRuntime cmd-обработчики + мост leaderstats.onChange→worker (globalEvent
leaderstatsChange) для bindToStat. Плеер пока НЕ портирован (по плану).

Тест-игра «Сбор монет с достижениями» id=2616 (is_test): поляна + 30 монет +
3 стата + 5 достижений (3 через bindToStat).

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

237 lines
13 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 */ }
}
_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 =
'<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;
}
}