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>
237 lines
13 KiB
JavaScript
237 lines
13 KiB
JavaScript
/**
|
||
* 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 => ({ '&': '&', '<': '<', '>': '>' }[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;
|
||
}
|
||
}
|