feat(studio): прогресс лидербордов и достижений сохраняется в БД (между сессиями)

GameRuntime.saveProgress/loadProgress — helper к storys savegame endpoint
(/kubikon3d/savegame/<pid>/<uid>/<ns>) для движковых менеджеров.

- Достижения: при unlock сохраняются в БД (namespace _achievements) + localStorage
  как быстрый кэш; loadFromDB при Play восстанавливает разблокированные.
- Лидерстаты: статы текущего игрока сохраняются в БД (namespace _leaderstats,
  дебаунс 1с при set); loadFromDB при Play восстанавливает значения.
- Загрузка из БД через 250мс после старта скриптов (даём define зарегистрировать).

Теперь прогресс игрока подгружается при каждой сессии с любого устройства.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
min 2026-06-06 09:59:11 +03:00
parent c9498b086e
commit 33cd435d06
4 changed files with 76 additions and 0 deletions

View File

@ -49,13 +49,26 @@ export class AchievementsManager {
} }
_loadSaved() { _loadSaved() {
// Резервная локальная копия (мгновенно, до ответа БД).
try { try {
const raw = localStorage.getItem(this._projectKey); const raw = localStorage.getItem(this._projectKey);
if (raw) for (const id of JSON.parse(raw)) this._unlocked.add(id); if (raw) for (const id of JSON.parse(raw)) this._unlocked.add(id);
} catch (e) { /* ignore */ } } 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() { _persist() {
// 1) локально (быстрый кэш), 2) в БД (между сессиями, любое устройство).
try { localStorage.setItem(this._projectKey, JSON.stringify([...this._unlocked])); } catch (e) {} try { localStorage.setItem(this._projectKey, JSON.stringify([...this._unlocked])); } catch (e) {}
try { this.s?.gameRuntime?.saveProgress?.('_achievements', [...this._unlocked]); } catch (e) {}
} }
unlock(id, _playerId) { unlock(id, _playerId) {

View File

@ -6203,6 +6203,13 @@ export class BabylonScene {
// Старт через requestAnimationFrame — даём Babylon собрать сцену // Старт через requestAnimationFrame — даём Babylon собрать сцену
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (this._isPlaying) this.gameRuntime?.start(this._scripts || []); 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);
}); });
// === Оружие === // === Оружие ===

View File

@ -4435,6 +4435,27 @@ export class GameRuntime {
.then(j => this._saveReply(scriptId, reqId, j.namespaces || {})) .then(j => this._saveReply(scriptId, reqId, j.namespaces || {}))
.catch(() => this._saveReply(scriptId, reqId, {})); .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) { _saveSet(payload) {
const url = this._saveBaseUrl(payload?.namespace); const url = this._saveBaseUrl(payload?.namespace);
if (!url) return; if (!url) return;

View File

@ -108,6 +108,41 @@ export class LeaderstatsManager {
for (const fn of this._onChange) { for (const fn of this._onChange) {
try { fn(pid, name, nv, old); } catch (e) { /* ignore */ } 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) { add(playerId, name, delta) {