studio/src/editor/engine/HistoryManager.js
МИН 31adbf151b Initial public release: Студия Рублокса v1.0
Open-source веб-студия для создания игр Рублокса, двойная лицензия
AGPL-3.0 + Коммерческая.

Главное:
- Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16
- Самодостаточный движок ~28к строк (66 файлов): BlockManager,
  TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController,
  ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов
- Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco)
- Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn)
- Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt)
- 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.)
- Конфигурируемый бэкенд через VITE_API_BASE — работает со staging
  (dev-api.rublox.pro) без настройки
- Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка
- Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING,
  SECURITY, CHANGELOG
- ESLint + Prettier + EditorConfig
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Перед публикацией:
- Все импорты из minecraftia заменены на локальные
- Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env
- Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо)
- AdminKubikonModeration не публикуется (модерация — в team.rublox.pro)
- 93 МБ ассетов public/kubikon-assets вынесены в .gitignore
  (раздаются через release artifact)
2026-05-27 23:41:10 +03:00

133 lines
4.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* HistoryManager — Undo/Redo через snapshot-историю.
*
* Каждое действие пользователя (поставил блок, сдвинул модель, удалил и т.д.)
* создаёт снимок состояния сцены через scene.serialize(). История хранит до
* MAX_HISTORY последних снимков. Ctrl+Z откатывает на предыдущий, Ctrl+Y возвращает.
*
* Простая реализация (не diff-based):
* - snapshot = JSON.stringify(scene.serialize())
* - размер на сцену ~10-50 КБ
* - 50 снапшотов = ~1-2 МБ памяти максимум
*
* Запись:
* markChange() — снять текущий snapshot (вызывается debounced при изменениях)
* Откат:
* undo() — применить предыдущий snapshot
* redo() — применить следующий
*/
const MAX_HISTORY = 50;
const DEBOUNCE_MS = 250; // не снимаем snapshot чаще чем раз в 250мс
export class HistoryManager {
/**
* @param {() => string} serializer — возвращает текущее состояние как строку
* @param {(state: object) => Promise<void>} restorer — восстанавливает состояние
*/
constructor(serializer, restorer) {
this.serializer = serializer;
this.restorer = restorer;
this._past = []; // [snapshot, ...] — история до текущего
this._future = []; // [snapshot, ...] — для redo (заполняется при undo)
this._current = null; // snapshot текущего состояния
this._debounceTimer = null;
this._isApplying = false; // защита от рекурсии при restoreFromState → markChange
}
/**
* Сделать snapshot текущего состояния. Debounced — частые изменения
* (например drag-постановка 30 блоков подряд) сольются в один snapshot.
*/
markChange() {
if (this._isApplying) return;
if (this._debounceTimer) clearTimeout(this._debounceTimer);
this._debounceTimer = setTimeout(() => {
this._debounceTimer = null;
this._takeSnapshot();
}, DEBOUNCE_MS);
}
/** Принудительный snapshot прямо сейчас (без debounce). */
flushPending() {
if (this._debounceTimer) {
clearTimeout(this._debounceTimer);
this._debounceTimer = null;
this._takeSnapshot();
}
}
_takeSnapshot() {
const snap = this.serializer();
if (!snap) return;
// Если ничего не изменилось — пропускаем
if (snap === this._current) return;
// Любая новая запись стирает future (классическое поведение)
if (this._current !== null) {
this._past.push(this._current);
if (this._past.length > MAX_HISTORY) this._past.shift();
}
this._current = snap;
this._future = [];
}
/** Инициализация: запомнить начальное состояние (вызвать после loadFromState). */
initialize() {
this._past = [];
this._future = [];
this._current = this.serializer();
}
/** Можно ли откатиться. */
canUndo() {
return this._past.length > 0;
}
/** Можно ли вернуть. */
canRedo() {
return this._future.length > 0;
}
async undo() {
this.flushPending();
if (!this.canUndo()) return false;
const prev = this._past.pop();
if (this._current !== null) this._future.push(this._current);
this._current = prev;
await this._apply(prev);
return true;
}
async redo() {
this.flushPending();
if (!this.canRedo()) return false;
const next = this._future.pop();
if (this._current !== null) this._past.push(this._current);
this._current = next;
await this._apply(next);
return true;
}
async _apply(snap) {
this._isApplying = true;
try {
const obj = JSON.parse(snap);
await this.restorer(obj);
} catch (e) {
// eslint-disable-next-line no-console
console.error('[HistoryManager] restore error', e);
} finally {
this._isApplying = false;
}
}
/** Полная очистка (при закрытии сцены). */
dispose() {
if (this._debounceTimer) clearTimeout(this._debounceTimer);
this._past = [];
this._future = [];
this._current = null;
}
}