From 33cd435d06eb5c9ef45312f0da339e56a4f5cce0 Mon Sep 17 00:00:00 2001 From: min Date: Sat, 6 Jun 2026 09:59:11 +0300 Subject: [PATCH] =?UTF-8?q?feat(studio):=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D1=81=20=D0=BB=D0=B8=D0=B4=D0=B5=D1=80=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=B4=D0=BE=D0=B2=20=D0=B8=20=D0=B4=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9=20=D1=81=D0=BE=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20=D0=B2=20=D0=91?= =?UTF-8?q?=D0=94=20(=D0=BC=D0=B5=D0=B6=D0=B4=D1=83=20=D1=81=D0=B5=D1=81?= =?UTF-8?q?=D1=81=D0=B8=D1=8F=D0=BC=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameRuntime.saveProgress/loadProgress — helper к storys savegame endpoint (/kubikon3d/savegame///) для движковых менеджеров. - Достижения: при unlock сохраняются в БД (namespace _achievements) + localStorage как быстрый кэш; loadFromDB при Play восстанавливает разблокированные. - Лидерстаты: статы текущего игрока сохраняются в БД (namespace _leaderstats, дебаунс 1с при set); loadFromDB при Play восстанавливает значения. - Загрузка из БД через 250мс после старта скриптов (даём define зарегистрировать). Теперь прогресс игрока подгружается при каждой сессии с любого устройства. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/AchievementsManager.js | 13 +++++++++ src/editor/engine/BabylonScene.js | 7 +++++ src/editor/engine/GameRuntime.js | 21 ++++++++++++++ src/editor/engine/LeaderstatsManager.js | 35 ++++++++++++++++++++++++ 4 files changed, 76 insertions(+) diff --git a/src/editor/engine/AchievementsManager.js b/src/editor/engine/AchievementsManager.js index 3944e9c..5f49b0b 100644 --- a/src/editor/engine/AchievementsManager.js +++ b/src/editor/engine/AchievementsManager.js @@ -49,13 +49,26 @@ export class AchievementsManager { } _loadSaved() { + // Резервная локальная копия (мгновенно, до ответа БД). try { const raw = localStorage.getItem(this._projectKey); if (raw) for (const id of JSON.parse(raw)) this._unlocked.add(id); } catch (e) { /* ignore */ } } + /** Загрузить разблокированные достижения из БД (по игроку). Вызывать при Play. */ + loadFromDB() { + const rt = this.s?.gameRuntime; + if (!rt || !rt.loadProgress) return; + rt.loadProgress('_achievements', (data) => { + if (Array.isArray(data)) { + for (const id of data) this._unlocked.add(id); + } + }); + } _persist() { + // 1) локально (быстрый кэш), 2) в БД (между сессиями, любое устройство). try { localStorage.setItem(this._projectKey, JSON.stringify([...this._unlocked])); } catch (e) {} + try { this.s?.gameRuntime?.saveProgress?.('_achievements', [...this._unlocked]); } catch (e) {} } unlock(id, _playerId) { diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index 6e46fc5..6ace30e 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -6203,6 +6203,13 @@ export class BabylonScene { // Старт через requestAnimationFrame — даём Babylon собрать сцену requestAnimationFrame(() => { if (this._isPlaying) this.gameRuntime?.start(this._scripts || []); + // Задача 20: подгрузить сохранённый прогресс игрока из БД ПОСЛЕ того, + // как скрипты вызвали define() (даём им 200мс на регистрацию статов). + setTimeout(() => { + if (!this._isPlaying) return; + try { this.achievements?.loadFromDB?.(); } catch (e) {} + try { this.leaderstats?.loadFromDB?.(); } catch (e) {} + }, 250); }); // === Оружие === diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 8499cab..869032c 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -4435,6 +4435,27 @@ export class GameRuntime { .then(j => this._saveReply(scriptId, reqId, j.namespaces || {})) .catch(() => this._saveReply(scriptId, reqId, {})); } + /** Публичный helper для движковых менеджеров (leaderstats/achievements): + * сохранить прогресс текущего игрока в БД (storys savegame). */ + saveProgress(namespace, data) { + const url = this._saveBaseUrl(namespace); + if (!url) return; + try { + fetch(url, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data }), + }).catch(() => {}); + } catch (e) { /* ignore */ } + } + /** Загрузить прогресс из БД (cb(data|null)). */ + loadProgress(namespace, cb) { + const url = this._saveBaseUrl(namespace); + if (!url) { cb && cb(null); return; } + fetch(url).then(r => r.json()) + .then(j => cb && cb(j.data ?? null)) + .catch(() => cb && cb(null)); + } + _saveSet(payload) { const url = this._saveBaseUrl(payload?.namespace); if (!url) return; diff --git a/src/editor/engine/LeaderstatsManager.js b/src/editor/engine/LeaderstatsManager.js index f3883a8..706bc78 100644 --- a/src/editor/engine/LeaderstatsManager.js +++ b/src/editor/engine/LeaderstatsManager.js @@ -108,6 +108,41 @@ export class LeaderstatsManager { for (const fn of this._onChange) { try { fn(pid, name, nv, old); } catch (e) { /* ignore */ } } + // Сохраняем статы текущего игрока в БД (дебаунс 1с) — между сессиями. + if (pid === this._resolveMe()) this._scheduleSave(); + } + + _scheduleSave() { + if (this._saveTimer) clearTimeout(this._saveTimer); + this._saveTimer = setTimeout(() => { + this._saveTimer = null; + try { + const me = this._resolveMe(); + const m = this._stats.get(me); + if (!m) return; + const obj = {}; for (const [k, v] of m) obj[k] = v; + this.s?.gameRuntime?.saveProgress?.('_leaderstats', obj); + } catch (e) { /* ignore */ } + }, 1000); + } + + /** Загрузить статы текущего игрока из БД (вызывать при Play, после define). */ + loadFromDB() { + const rt = this.s?.gameRuntime; + if (!rt || !rt.loadProgress) return; + rt.loadProgress('_leaderstats', (data) => { + if (data && typeof data === 'object') { + const me = this._resolveMe(); + for (const name of Object.keys(data)) { + // Применяем только к зарегистрированным статам, без повторного сейва. + if (this._defs.some(d => d.name === name)) { + this._ensure(me, name); + this._stats.get(me).set(name, Number(data[name]) || 0); + } + } + this._dirty = true; + } + }); } add(playerId, name, delta) {