diff --git a/src/editor/engine/AchievementsManager.js b/src/editor/engine/AchievementsManager.js new file mode 100644 index 0000000..3944e9c --- /dev/null +++ b/src/editor/engine/AchievementsManager.js @@ -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 = + '
' + 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; + } +} diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index f2c90c8..6e46fc5 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -74,6 +74,8 @@ import { ZombieSpawnerManager } from './ZombieSpawnerManager'; import { DynamicsManager } from './DynamicsManager'; import { Environment } from './Environment'; import { SkyboxManager } from './SkyboxManager'; +import { LeaderstatsManager } from './LeaderstatsManager'; +import { AchievementsManager } from './AchievementsManager'; import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager'; import { GameAudioManager } from './GameAudioManager'; import { AssetManager } from './AssetManager'; @@ -1297,6 +1299,8 @@ export class BabylonScene { this.dynamics = new DynamicsManager(this); this.environment = new Environment(this.scene, this._hemiLight, this._sunLight); 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.assetManager = new AssetManager(); // PrimitiveManager должен уметь брать dataURL картинки по id ассета, @@ -1454,6 +1458,10 @@ export class BabylonScene { if (this.skybox) { this.skybox.tick(dt); } + // Лидерборды (задача 20) — рендер HUD-таблицы при изменениях. + if (this._isPlaying && this.leaderstats) { + this.leaderstats.tick(); + } // Анимация жидкостей — работает всегда (и в редакторе) if (this.blockManager) { this.blockManager.tick(dt); @@ -6188,6 +6196,10 @@ export class BabylonScene { // Перед стартом чистим скрипты-сироты (их объект-носитель удалён) — // иначе on-target скрипт удалённого объекта продолжал исполняться. Баг 2026-06-05. 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(() => { if (this._isPlaying) this.gameRuntime?.start(this._scripts || []); @@ -7609,6 +7621,8 @@ export class BabylonScene { shadowQuality: this._shadowQuality || 'soft', environment: this.environment ? this.environment.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, // Библиотека пользовательских картинок (текстуры/GUI-image). assets: this.assetManager ? this.assetManager.serialize() : [], @@ -8094,6 +8108,13 @@ export class BabylonScene { if (state.scene.skybox && this.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) { this.audioManager.load(state.scene.audio); @@ -8161,6 +8182,9 @@ export class BabylonScene { this._isPlaying = false; // Задача 04: закрываем любой активный модал чтобы не «висел» при стопе try { this.modalManager?._instantClose?.(); } catch (e) {} + // Задача 20: чистим рантайм лидербордов/достижений (определения остаются). + try { this.leaderstats?.resetRuntime?.(); } catch (e) {} + try { this.achievements?.resetRuntime?.(); } catch (e) {} // Сбрасываем таймер прохождения this._timerRunning = false; this._timerStartedAt = null; diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index af975dc..8499cab 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -69,6 +69,21 @@ export class GameRuntime { this.stop(); this._isRunning = true; 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 console.log('[GameRuntime] start called with scripts:', scripts); // Phase 6.5: lazy-init физ-мира. Wasm-инициализация асинхронная (~50мс), @@ -2895,6 +2910,40 @@ export class GameRuntime { return; } // === Небо и атмосфера (задача 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') { try { this.scene3d?.skybox?.setSkybox(payload?.opts || {}); } catch (e) {} return; diff --git a/src/editor/engine/LeaderstatsManager.js b/src/editor/engine/LeaderstatsManager.js new file mode 100644 index 0000000..f3883a8 --- /dev/null +++ b/src/editor/engine/LeaderstatsManager.js @@ -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 = '
🏆 Таблица лидеров
'; + // Шапка столбцов. + html += '
'; + html += 'Игрок'; + for (const d of defs) html += '' + (d.icon ? d.icon + ' ' : '') + d.name + ''; + html += '
'; + // Строки. + for (const r of rows) { + const mine = r.pid === me; + html += '
'; + html += '' + this._esc(r.name) + ''; + 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 += '' + fmt(this.get(r.pid, d.name), d.format) + ''; + } + html += '
'; + } + 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 = ''; + } +} diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index 0cdfe10..067b2b9 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -74,6 +74,10 @@ let _toolSeq = 0; let _players = { me: null, list: [] }; // Общее состояние комнаты game.room.get/set — зеркало из main thread. 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). let _playerJoinHandlers = []; let _playerLeaveHandlers = []; @@ -3191,6 +3195,62 @@ const game = { 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) — данные, видимые всем игрокам. * В одиночной игре работает как локальное хранилище. @@ -4278,6 +4338,15 @@ self.onmessage = (e) => { const t = payload?.type; if (t === 'click') { 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') { for (const fn of _mouseMoveHandlers) { try { fn(payload.x, payload.y); }