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)
133 lines
4.9 KiB
JavaScript
133 lines
4.9 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
}
|