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>
This commit is contained in:
parent
cf34f9cdb6
commit
5d49cd9eeb
236
src/editor/engine/AchievementsManager.js
Normal file
236
src/editor/engine/AchievementsManager.js
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -74,6 +74,8 @@ import { ZombieSpawnerManager } from './ZombieSpawnerManager';
|
|||||||
import { DynamicsManager } from './DynamicsManager';
|
import { DynamicsManager } from './DynamicsManager';
|
||||||
import { Environment } from './Environment';
|
import { Environment } from './Environment';
|
||||||
import { SkyboxManager } from './SkyboxManager';
|
import { SkyboxManager } from './SkyboxManager';
|
||||||
|
import { LeaderstatsManager } from './LeaderstatsManager';
|
||||||
|
import { AchievementsManager } from './AchievementsManager';
|
||||||
import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager';
|
import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager';
|
||||||
import { GameAudioManager } from './GameAudioManager';
|
import { GameAudioManager } from './GameAudioManager';
|
||||||
import { AssetManager } from './AssetManager';
|
import { AssetManager } from './AssetManager';
|
||||||
@ -1297,6 +1299,8 @@ export class BabylonScene {
|
|||||||
this.dynamics = new DynamicsManager(this);
|
this.dynamics = new DynamicsManager(this);
|
||||||
this.environment = new Environment(this.scene, this._hemiLight, this._sunLight);
|
this.environment = new Environment(this.scene, this._hemiLight, this._sunLight);
|
||||||
this.skybox = new SkyboxManager(this.scene, this._hemiLight, this._sunLight); // задача 16 — кастомное небо (единый источник света)
|
this.skybox = new SkyboxManager(this.scene, this._hemiLight, this._sunLight); // задача 16 — кастомное небо (единый источник света)
|
||||||
|
this.leaderstats = new LeaderstatsManager(this); // задача 20 — лидерборды
|
||||||
|
this.achievements = new AchievementsManager(this); // задача 20 — достижения
|
||||||
this.audioManager = new AudioManager();
|
this.audioManager = new AudioManager();
|
||||||
this.assetManager = new AssetManager();
|
this.assetManager = new AssetManager();
|
||||||
// PrimitiveManager должен уметь брать dataURL картинки по id ассета,
|
// PrimitiveManager должен уметь брать dataURL картинки по id ассета,
|
||||||
@ -1454,6 +1458,10 @@ export class BabylonScene {
|
|||||||
if (this.skybox) {
|
if (this.skybox) {
|
||||||
this.skybox.tick(dt);
|
this.skybox.tick(dt);
|
||||||
}
|
}
|
||||||
|
// Лидерборды (задача 20) — рендер HUD-таблицы при изменениях.
|
||||||
|
if (this._isPlaying && this.leaderstats) {
|
||||||
|
this.leaderstats.tick();
|
||||||
|
}
|
||||||
// Анимация жидкостей — работает всегда (и в редакторе)
|
// Анимация жидкостей — работает всегда (и в редакторе)
|
||||||
if (this.blockManager) {
|
if (this.blockManager) {
|
||||||
this.blockManager.tick(dt);
|
this.blockManager.tick(dt);
|
||||||
@ -6188,6 +6196,10 @@ export class BabylonScene {
|
|||||||
// Перед стартом чистим скрипты-сироты (их объект-носитель удалён) —
|
// Перед стартом чистим скрипты-сироты (их объект-носитель удалён) —
|
||||||
// иначе on-target скрипт удалённого объекта продолжал исполняться. Баг 2026-06-05.
|
// иначе on-target скрипт удалённого объекта продолжал исполняться. Баг 2026-06-05.
|
||||||
this._cleanupOrphanScripts?.();
|
this._cleanupOrphanScripts?.();
|
||||||
|
// Задача 20: смонтировать HUD лидербордов/достижений если определения уже
|
||||||
|
// загружены из проекта (define из project_data при load).
|
||||||
|
try { if (this.leaderstats?.active) this.leaderstats._mount(); } catch (e) {}
|
||||||
|
try { if (this.achievements?.active) this.achievements._mountButton(); } catch (e) {}
|
||||||
// Старт через requestAnimationFrame — даём Babylon собрать сцену
|
// Старт через requestAnimationFrame — даём Babylon собрать сцену
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (this._isPlaying) this.gameRuntime?.start(this._scripts || []);
|
if (this._isPlaying) this.gameRuntime?.start(this._scripts || []);
|
||||||
@ -7609,6 +7621,8 @@ export class BabylonScene {
|
|||||||
shadowQuality: this._shadowQuality || 'soft',
|
shadowQuality: this._shadowQuality || 'soft',
|
||||||
environment: this.environment ? this.environment.serialize() : null,
|
environment: this.environment ? this.environment.serialize() : null,
|
||||||
skybox: this.skybox ? this.skybox.serialize() : null,
|
skybox: this.skybox ? this.skybox.serialize() : null,
|
||||||
|
leaderstats: this.leaderstats ? this.leaderstats.serialize() : null,
|
||||||
|
achievements: this.achievements ? this.achievements.serialize() : null,
|
||||||
audio: this.audioManager ? this.audioManager.serialize() : null,
|
audio: this.audioManager ? this.audioManager.serialize() : null,
|
||||||
// Библиотека пользовательских картинок (текстуры/GUI-image).
|
// Библиотека пользовательских картинок (текстуры/GUI-image).
|
||||||
assets: this.assetManager ? this.assetManager.serialize() : [],
|
assets: this.assetManager ? this.assetManager.serialize() : [],
|
||||||
@ -8094,6 +8108,13 @@ export class BabylonScene {
|
|||||||
if (state.scene.skybox && this.skybox) {
|
if (state.scene.skybox && this.skybox) {
|
||||||
this.skybox.load(state.scene.skybox);
|
this.skybox.load(state.scene.skybox);
|
||||||
}
|
}
|
||||||
|
// Лидерборды и достижения (задача 20) — определения из проекта.
|
||||||
|
if (state.scene.leaderstats && this.leaderstats) {
|
||||||
|
this.leaderstats.load(state.scene.leaderstats);
|
||||||
|
}
|
||||||
|
if (state.scene.achievements && this.achievements) {
|
||||||
|
this.achievements.load(state.scene.achievements);
|
||||||
|
}
|
||||||
// Аудио (фоновая музыка/амбиент)
|
// Аудио (фоновая музыка/амбиент)
|
||||||
if (state.scene.audio && this.audioManager) {
|
if (state.scene.audio && this.audioManager) {
|
||||||
this.audioManager.load(state.scene.audio);
|
this.audioManager.load(state.scene.audio);
|
||||||
@ -8161,6 +8182,9 @@ export class BabylonScene {
|
|||||||
this._isPlaying = false;
|
this._isPlaying = false;
|
||||||
// Задача 04: закрываем любой активный модал чтобы не «висел» при стопе
|
// Задача 04: закрываем любой активный модал чтобы не «висел» при стопе
|
||||||
try { this.modalManager?._instantClose?.(); } catch (e) {}
|
try { this.modalManager?._instantClose?.(); } catch (e) {}
|
||||||
|
// Задача 20: чистим рантайм лидербордов/достижений (определения остаются).
|
||||||
|
try { this.leaderstats?.resetRuntime?.(); } catch (e) {}
|
||||||
|
try { this.achievements?.resetRuntime?.(); } catch (e) {}
|
||||||
// Сбрасываем таймер прохождения
|
// Сбрасываем таймер прохождения
|
||||||
this._timerRunning = false;
|
this._timerRunning = false;
|
||||||
this._timerStartedAt = null;
|
this._timerStartedAt = null;
|
||||||
|
|||||||
@ -69,6 +69,21 @@ export class GameRuntime {
|
|||||||
this.stop();
|
this.stop();
|
||||||
this._isRunning = true;
|
this._isRunning = true;
|
||||||
this.scripts = scripts || []; // для привязки логов/ошибок к скрипту
|
this.scripts = scripts || []; // для привязки логов/ошибок к скрипту
|
||||||
|
// Задача 20: мост leaderstats.onChange (main) → globalEvent в worker'ы,
|
||||||
|
// чтобы скриптовые game.leaderstats.onChange и bindToStat срабатывали.
|
||||||
|
try {
|
||||||
|
const ls = this.scene3d?.leaderstats;
|
||||||
|
if (ls && !ls._bridgeBound) {
|
||||||
|
ls._bridgeBound = true;
|
||||||
|
const meId = ls._resolveMe?.();
|
||||||
|
ls.onChange((pid, name, nv, ov) => {
|
||||||
|
for (const sb of this.sandboxes) sb.sendGlobalEvent({
|
||||||
|
type: 'leaderstatsChange', playerId: pid, name, newValue: nv, oldValue: ov,
|
||||||
|
isMe: String(pid) === String(meId),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log('[GameRuntime] start called with scripts:', scripts);
|
console.log('[GameRuntime] start called with scripts:', scripts);
|
||||||
// Phase 6.5: lazy-init физ-мира. Wasm-инициализация асинхронная (~50мс),
|
// Phase 6.5: lazy-init физ-мира. Wasm-инициализация асинхронная (~50мс),
|
||||||
@ -2895,6 +2910,40 @@ export class GameRuntime {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// === Небо и атмосфера (задача 16) ===
|
// === Небо и атмосфера (задача 16) ===
|
||||||
|
// === Лидерборды и достижения (задача 20) ===
|
||||||
|
if (cmd === 'leaderstats.define') {
|
||||||
|
try { this.scene3d?.leaderstats?.define(payload.name, payload.opts || {}); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'leaderstats.set') {
|
||||||
|
try { this.scene3d?.leaderstats?.set(payload.playerId, payload.name, payload.value); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'leaderstats.add') {
|
||||||
|
try { this.scene3d?.leaderstats?.add(payload.playerId, payload.name, payload.delta); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'achievements.define') {
|
||||||
|
try { this.scene3d?.achievements?.define(payload.list); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'achievements.unlock') {
|
||||||
|
try { this.scene3d?.achievements?.unlock(payload.id, payload.playerId); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'achievements.bindToStat') {
|
||||||
|
try { this.scene3d?.achievements?.bindToStat(payload.id, payload.statName, payload.cond || {}); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'achievements.setButtonVisible') {
|
||||||
|
try { this.scene3d?.achievements?.setButtonVisible(!!payload.visible); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'achievements.openPage') {
|
||||||
|
try { this.scene3d?.achievements?.openPage(); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (cmd === 'scene.setSkybox') {
|
if (cmd === 'scene.setSkybox') {
|
||||||
try { this.scene3d?.skybox?.setSkybox(payload?.opts || {}); } catch (e) {}
|
try { this.scene3d?.skybox?.setSkybox(payload?.opts || {}); } catch (e) {}
|
||||||
return;
|
return;
|
||||||
|
|||||||
220
src/editor/engine/LeaderstatsManager.js
Normal file
220
src/editor/engine/LeaderstatsManager.js
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* LeaderstatsManager — лидерборды (leaderstats) как в Roblox (задача 20).
|
||||||
|
*
|
||||||
|
* Хранит статы игроков и рендерит HUD-таблицу в правом-верхнем углу.
|
||||||
|
* В одиночной игре — один игрок ('me'). Поля сортируются по primary-стату.
|
||||||
|
*
|
||||||
|
* API (через game.leaderstats.*):
|
||||||
|
* define(name, opts) — зарегистрировать стат (initial/format/icon/color/primary)
|
||||||
|
* set(playerId, name, value) / add — изменить стат игрока
|
||||||
|
* get(playerId, name) — прочитать
|
||||||
|
* me.set/add(name, value) — для текущего игрока
|
||||||
|
* onChange(fn) — подписка (для bindToStat достижений)
|
||||||
|
*
|
||||||
|
* format: 'number' (42) | 'time' (mm:ss) | 'short' (1.2K).
|
||||||
|
* DOM-оверлей крепится к canvas.parentElement (как LoadingScreenOverlay).
|
||||||
|
*
|
||||||
|
* Фича-парность: тот же модуль в rublox-player/src/engine/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function fmt(value, format) {
|
||||||
|
const v = Number(value) || 0;
|
||||||
|
if (format === 'time') {
|
||||||
|
const m = Math.floor(v / 60), s = Math.floor(v % 60);
|
||||||
|
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
||||||
|
}
|
||||||
|
if (format === 'short') {
|
||||||
|
if (v >= 1e9) return (v / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
|
||||||
|
if (v >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
|
||||||
|
if (v >= 1e3) return (v / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
|
||||||
|
return String(Math.round(v));
|
||||||
|
}
|
||||||
|
return String(Math.round(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LeaderstatsManager {
|
||||||
|
constructor(scene3d) {
|
||||||
|
this.s = scene3d;
|
||||||
|
this._defs = []; // [{name, initial, format, icon, color, primary}]
|
||||||
|
this._stats = new Map(); // playerId → Map(name → value)
|
||||||
|
this._players = new Map(); // playerId → displayName
|
||||||
|
this._onChange = [];
|
||||||
|
this.root = null;
|
||||||
|
this._dirty = false;
|
||||||
|
this._meId = 'me';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** id текущего игрока (одиночка = 'me'). */
|
||||||
|
_resolveMe() {
|
||||||
|
try {
|
||||||
|
const p = this.s?.gameRuntime?._players?.me;
|
||||||
|
if (p && p.id != null) return String(p.id);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return 'me';
|
||||||
|
}
|
||||||
|
|
||||||
|
define(name, opts = {}) {
|
||||||
|
if (typeof name !== 'string' || !name) return;
|
||||||
|
if (this._defs.some(d => d.name === name)) return; // уже есть
|
||||||
|
this._defs.push({
|
||||||
|
name,
|
||||||
|
initial: Number(opts.initial) || 0,
|
||||||
|
format: opts.format || 'number',
|
||||||
|
icon: opts.icon || '',
|
||||||
|
color: opts.color || '#e8ecf2',
|
||||||
|
primary: !!opts.primary,
|
||||||
|
});
|
||||||
|
// Если ни один не primary — первый становится primary.
|
||||||
|
if (!this._defs.some(d => d.primary)) this._defs[0].primary = true;
|
||||||
|
// Инициализируем стат у уже известных игроков.
|
||||||
|
for (const [pid] of this._players) this._ensure(pid, name);
|
||||||
|
this._ensureMe();
|
||||||
|
if (this.s?._isPlaying) this._mount(); // HUD только в Play
|
||||||
|
this._dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureMe() {
|
||||||
|
const me = this._resolveMe();
|
||||||
|
this._meId = me;
|
||||||
|
if (!this._players.has(me)) {
|
||||||
|
let nm = 'Ты';
|
||||||
|
try { nm = this.s?.gameRuntime?._players?.me?.name || 'Ты'; } catch (e) {}
|
||||||
|
this._players.set(me, nm);
|
||||||
|
}
|
||||||
|
for (const d of this._defs) this._ensure(me, d.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensure(pid, name) {
|
||||||
|
if (!this._stats.has(pid)) this._stats.set(pid, new Map());
|
||||||
|
const m = this._stats.get(pid);
|
||||||
|
if (!m.has(name)) {
|
||||||
|
const def = this._defs.find(d => d.name === name);
|
||||||
|
m.set(name, def ? def.initial : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set(playerId, name, value) {
|
||||||
|
const pid = playerId == null ? this._resolveMe() : String(playerId);
|
||||||
|
if (!this._players.has(pid)) this._players.set(pid, pid === this._resolveMe() ? 'Ты' : ('Игрок ' + pid));
|
||||||
|
this._ensure(pid, name);
|
||||||
|
const m = this._stats.get(pid);
|
||||||
|
const old = m.get(name);
|
||||||
|
const nv = Number(value) || 0;
|
||||||
|
if (old === nv) return;
|
||||||
|
m.set(name, nv);
|
||||||
|
this._dirty = true;
|
||||||
|
this._flash = this._flash || {};
|
||||||
|
this._flash[pid + '|' + name] = performance.now ? performance.now() : Date.now();
|
||||||
|
for (const fn of this._onChange) {
|
||||||
|
try { fn(pid, name, nv, old); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add(playerId, name, delta) {
|
||||||
|
const pid = playerId == null ? this._resolveMe() : String(playerId);
|
||||||
|
this._ensure(pid, name);
|
||||||
|
const cur = this._stats.get(pid).get(name) || 0;
|
||||||
|
this.set(pid, name, cur + (Number(delta) || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
get(playerId, name) {
|
||||||
|
const pid = playerId == null ? this._resolveMe() : String(playerId);
|
||||||
|
const m = this._stats.get(pid);
|
||||||
|
return m ? (m.get(name) || 0) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(fn) { if (typeof fn === 'function') this._onChange.push(fn); }
|
||||||
|
|
||||||
|
/** Активны ли leaderstats (хотя бы один define). */
|
||||||
|
get active() { return this._defs.length > 0; }
|
||||||
|
|
||||||
|
// ── HUD ──────────────────────────────────────────────────────────────
|
||||||
|
_mount() {
|
||||||
|
if (this.root) return;
|
||||||
|
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
|
||||||
|
const root = document.createElement('div');
|
||||||
|
root.style.cssText = [
|
||||||
|
'position:absolute', 'top:14px', 'right:14px', 'z-index:50',
|
||||||
|
'min-width:230px', 'max-width:300px',
|
||||||
|
'background:rgba(18,22,33,0.55)', 'backdrop-filter:blur(8px)',
|
||||||
|
'-webkit-backdrop-filter:blur(8px)',
|
||||||
|
'border:1px solid rgba(255,255,255,0.12)', 'border-radius:12px',
|
||||||
|
'padding:10px 12px', 'font-family:Inter,system-ui,sans-serif',
|
||||||
|
'color:#e8ecf2', 'pointer-events:none', 'user-select:none',
|
||||||
|
'box-shadow:0 6px 24px rgba(0,0,0,0.35)',
|
||||||
|
].join(';');
|
||||||
|
parent.appendChild(root);
|
||||||
|
this.root = root;
|
||||||
|
this._sortBy = null; // имя стата для сортировки (null = primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Вызывать каждый кадр (рендер при изменениях + затухание flash). */
|
||||||
|
tick() {
|
||||||
|
if (!this.active) return;
|
||||||
|
if (!this.root) { this._mount(); this._dirty = true; }
|
||||||
|
if (this._dirty) { this._render(); this._dirty = false; }
|
||||||
|
// flash затухает ~600мс — перерисуем пока активен.
|
||||||
|
if (this._flash && Object.keys(this._flash).length) {
|
||||||
|
const now = performance.now ? performance.now() : Date.now();
|
||||||
|
let any = false;
|
||||||
|
for (const k of Object.keys(this._flash)) {
|
||||||
|
if (now - this._flash[k] < 600) any = true; else delete this._flash[k];
|
||||||
|
}
|
||||||
|
if (any) this._render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_render() {
|
||||||
|
const defs = this._defs;
|
||||||
|
if (!defs.length) { this.root.innerHTML = ''; return; }
|
||||||
|
const sortStat = this._sortBy || (defs.find(d => d.primary) || defs[0]).name;
|
||||||
|
const me = this._resolveMe();
|
||||||
|
// Строки игроков, сортировка по убыванию sortStat, топ-10.
|
||||||
|
const rows = [...this._players.keys()]
|
||||||
|
.map(pid => ({ pid, name: this._players.get(pid) }))
|
||||||
|
.sort((a, b) => (this.get(b.pid, sortStat) - this.get(a.pid, sortStat)))
|
||||||
|
.slice(0, 10);
|
||||||
|
const now = performance.now ? performance.now() : Date.now();
|
||||||
|
|
||||||
|
let html = '<div style="display:flex;align-items:center;gap:6px;font-weight:800;font-size:13px;margin-bottom:8px;color:#ffd23a">🏆 Таблица лидеров</div>';
|
||||||
|
// Шапка столбцов.
|
||||||
|
html += '<div style="display:grid;grid-template-columns:1fr ' + defs.map(() => 'auto').join(' ') + ';gap:8px;font-size:11px;color:#9aa3b2;font-weight:700;padding-bottom:4px;border-bottom:1px solid rgba(255,255,255,0.1)">';
|
||||||
|
html += '<span>Игрок</span>';
|
||||||
|
for (const d of defs) html += '<span style="text-align:right;color:' + d.color + '">' + (d.icon ? d.icon + ' ' : '') + d.name + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
// Строки.
|
||||||
|
for (const r of rows) {
|
||||||
|
const mine = r.pid === me;
|
||||||
|
html += '<div style="display:grid;grid-template-columns:1fr ' + defs.map(() => 'auto').join(' ') + ';gap:8px;font-size:13px;padding:4px 2px;border-radius:6px;' + (mine ? 'background:rgba(51,87,255,0.22);' : '') + '">';
|
||||||
|
html += '<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:' + (mine ? '800' : '600') + '">' + this._esc(r.name) + '</span>';
|
||||||
|
for (const d of defs) {
|
||||||
|
const flashed = this._flash && (now - (this._flash[r.pid + '|' + d.name] || 0) < 600);
|
||||||
|
const col = flashed ? '#ffe066' : d.color;
|
||||||
|
html += '<span style="text-align:right;font-weight:700;color:' + col + ';transition:color .2s">' + fmt(this.get(r.pid, d.name), d.format) + '</span>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
this.root.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
_esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
||||||
|
|
||||||
|
/** Сериализация определений в project_data. */
|
||||||
|
serialize() {
|
||||||
|
return this._defs.map(d => ({ ...d }));
|
||||||
|
}
|
||||||
|
load(arr) {
|
||||||
|
if (!Array.isArray(arr)) return;
|
||||||
|
for (const d of arr) this.define(d.name, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.root) { try { this.root.remove(); } catch (e) {} this.root = null; }
|
||||||
|
this._stats.clear(); this._players.clear(); this._onChange = [];
|
||||||
|
}
|
||||||
|
/** Сброс рантайм-значений при exitPlayMode (определения остаются). */
|
||||||
|
resetRuntime() {
|
||||||
|
this._stats.clear(); this._players.clear(); this._flash = {};
|
||||||
|
if (this.root) this.root.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -74,6 +74,10 @@ let _toolSeq = 0;
|
|||||||
let _players = { me: null, list: [] };
|
let _players = { me: null, list: [] };
|
||||||
// Общее состояние комнаты game.room.get/set — зеркало из main thread.
|
// Общее состояние комнаты game.room.get/set — зеркало из main thread.
|
||||||
let _roomState = {};
|
let _roomState = {};
|
||||||
|
// Задача 20: зеркала лидербордов/достижений для синхронного get/has в скриптах.
|
||||||
|
let _lsMirror = {}; // { playerId: { statName: value } }
|
||||||
|
let _achUnlocked = {}; // { id: true }
|
||||||
|
let _lsChangeHandlers = []; // game.leaderstats.onChange подписки
|
||||||
// Подписки game.onPlayerJoin / onPlayerLeave / game.onMessage(name).
|
// Подписки game.onPlayerJoin / onPlayerLeave / game.onMessage(name).
|
||||||
let _playerJoinHandlers = [];
|
let _playerJoinHandlers = [];
|
||||||
let _playerLeaveHandlers = [];
|
let _playerLeaveHandlers = [];
|
||||||
@ -3191,6 +3195,62 @@ const game = {
|
|||||||
return p ? { ...p } : null;
|
return p ? { ...p } : null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// === Лидерборды (leaderstats) — задача 20 ===
|
||||||
|
leaderstats: {
|
||||||
|
/** Зарегистрировать стат: define('Монеты', {initial,format,icon,color,primary}). */
|
||||||
|
define(name, opts) {
|
||||||
|
if (typeof name !== 'string' || !name) return;
|
||||||
|
_send('leaderstats.define', { name, opts: opts || {} });
|
||||||
|
},
|
||||||
|
/** Установить стат игрока (playerId=null → текущий). */
|
||||||
|
set(playerId, name, value) {
|
||||||
|
_send('leaderstats.set', { playerId: playerId == null ? null : String(playerId), name, value: Number(value) || 0 });
|
||||||
|
const pid = playerId == null ? '@me' : String(playerId);
|
||||||
|
if (!_lsMirror[pid]) _lsMirror[pid] = {};
|
||||||
|
_lsMirror[pid][name] = Number(value) || 0;
|
||||||
|
},
|
||||||
|
/** Прибавить к стату. */
|
||||||
|
add(playerId, name, delta) {
|
||||||
|
_send('leaderstats.add', { playerId: playerId == null ? null : String(playerId), name, delta: Number(delta) || 0 });
|
||||||
|
const pid = playerId == null ? '@me' : String(playerId);
|
||||||
|
if (!_lsMirror[pid]) _lsMirror[pid] = {};
|
||||||
|
_lsMirror[pid][name] = (_lsMirror[pid][name] || 0) + (Number(delta) || 0);
|
||||||
|
},
|
||||||
|
/** Прочитать стат (из локального зеркала). */
|
||||||
|
get(playerId, name) {
|
||||||
|
const pid = playerId == null ? '@me' : String(playerId);
|
||||||
|
return (_lsMirror[pid] && _lsMirror[pid][name]) || 0;
|
||||||
|
},
|
||||||
|
/** Подписка на изменение: onChange((playerId, name, newVal, oldVal) => {}). */
|
||||||
|
onChange(fn) { if (typeof fn === 'function') _lsChangeHandlers.push(fn); },
|
||||||
|
/** Шорткат для текущего игрока. */
|
||||||
|
me: {
|
||||||
|
set(name, value) { game.leaderstats.set(null, name, value); },
|
||||||
|
add(name, delta) { game.leaderstats.add(null, name, delta); },
|
||||||
|
get(name) { return game.leaderstats.get(null, name); },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Достижения — задача 20 ===
|
||||||
|
achievements: {
|
||||||
|
/** Объявить достижения: define([{id,name,description,icon,rarity,points,hidden}]). */
|
||||||
|
define(list) { _send('achievements.define', { list: Array.isArray(list) ? list : [list] }); },
|
||||||
|
/** Разблокировать достижение. */
|
||||||
|
unlock(id, playerId) {
|
||||||
|
if (typeof id !== 'string') return;
|
||||||
|
_achUnlocked[id] = true;
|
||||||
|
_send('achievements.unlock', { id, playerId: playerId == null ? null : String(playerId) });
|
||||||
|
},
|
||||||
|
/** Разблокировано ли (из зеркала). */
|
||||||
|
has(id) { return !!_achUnlocked[id]; },
|
||||||
|
/** Авто-unlock по достижению значения leaderstat. */
|
||||||
|
bindToStat(id, statName, cond) { _send('achievements.bindToStat', { id, statName, cond: cond || {} }); },
|
||||||
|
/** Показать/скрыть кнопку-кубок. */
|
||||||
|
setButtonVisible(v) { _send('achievements.setButtonVisible', { visible: !!v }); },
|
||||||
|
/** Открыть страницу достижений. */
|
||||||
|
openPage() { _send('achievements.openPage', {}); },
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Общее состояние комнаты (Фаза 4.3) — данные, видимые всем игрокам.
|
* Общее состояние комнаты (Фаза 4.3) — данные, видимые всем игрокам.
|
||||||
* В одиночной игре работает как локальное хранилище.
|
* В одиночной игре работает как локальное хранилище.
|
||||||
@ -4278,6 +4338,15 @@ self.onmessage = (e) => {
|
|||||||
const t = payload?.type;
|
const t = payload?.type;
|
||||||
if (t === 'click') {
|
if (t === 'click') {
|
||||||
for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick');
|
for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick');
|
||||||
|
} else if (t === 'leaderstatsChange') {
|
||||||
|
// Задача 20: стат изменился на сервере/main — обновляем зеркало + onChange.
|
||||||
|
const pid = payload.playerId == null ? '@me' : String(payload.playerId);
|
||||||
|
if (!_lsMirror[pid]) _lsMirror[pid] = {};
|
||||||
|
_lsMirror[pid][payload.name] = payload.newValue;
|
||||||
|
if (payload.isMe) { if (!_lsMirror['@me']) _lsMirror['@me'] = {}; _lsMirror['@me'][payload.name] = payload.newValue; }
|
||||||
|
for (const fn of _lsChangeHandlers) { try { fn(payload.playerId, payload.name, payload.newValue, payload.oldValue); } catch (err) { _send('log', { level: 'error', text: 'leaderstats.onChange: ' + (err && err.message ? err.message : err) }); } }
|
||||||
|
} else if (t === 'achievementUnlocked') {
|
||||||
|
_achUnlocked[payload.id] = true;
|
||||||
} else if (t === 'mouseMove') {
|
} else if (t === 'mouseMove') {
|
||||||
for (const fn of _mouseMoveHandlers) {
|
for (const fn of _mouseMoveHandlers) {
|
||||||
try { fn(payload.x, payload.y); }
|
try { fn(payload.x, payload.y); }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user