Game/EditorUndoHistory.cs
using System.Collections.Generic;
namespace BlockParty;
/// <summary>
/// Snapshot-based undo/redo history for the level editor (Ctrl+Z / Ctrl+Shift+Z in
/// <see cref="LevelEditorStage"/>). Entries are whole-level <see cref="EditorLevel.ToJsonString"/>
/// snapshots (a level is a few KB, so a deep history is cheap) — no per-operation command objects.
/// Each entry also carries the document's session-only <see cref="EditorLevel.ProjectSourceId"/>, so
/// undoing a document-replacing operation (reset, paste-import, template load) restores the right
/// to update the original file along with its layout, plus the document number those operations bump
/// — that's what tells two never-saved documents (both source-less) apart when one is saved.
/// Owned by <see cref="GameManager"/> rather than the stage so the history survives test-play round
/// trips and stage rebuilds, like the remembered editor session; never persisted to disk.
/// </summary>
public sealed class EditorUndoHistory
{
/// <summary>One history state: the full-authoring level JSON, the project file it may update, and
/// which document it belongs to (see <see cref="_document"/>).</summary>
public readonly record struct Entry( string Json, string SourceId, int Document );
private const int MAX_ENTRIES = 100;
/// <summary>Rapid same-key checkpoints (typing in a HUD field, repeated nudges) within this window
/// merge into the entry started by the first of them, so one undo reverts the whole burst.</summary>
private const float COALESCE_SECONDS = 1.0f;
private readonly List<Entry> _undo = new();
private readonly List<Entry> _redo = new();
/// <summary>Snapshot of the level as of the last checkpoint — the state undo steps back FROM.</summary>
private Entry? _current;
private string _lastKey;
private float _lastTime;
private float _time;
/// <summary>Which document the entries being recorded belong to — bumped by every operation that
/// replaces the open document. Identity can't be the source id: a never-saved document has none,
/// so two of them in one history would share the null tag and a save would retag both.</summary>
private int _document;
public bool CanUndo => _undo.Count > 0;
public bool CanRedo => _redo.Count > 0;
/// <summary>Advance the coalescing clock (real seconds; called once per editor frame). Monotonic
/// across stage rebuilds because this object outlives the stage.</summary>
public void Advance( float dt ) => _time += dt;
/// <summary>Start a fresh history for a newly opened level (undo never crosses documents).</summary>
public void Reset( string snapshot, string sourceId )
{
_undo.Clear();
_redo.Clear();
_current = new Entry( snapshot, sourceId, ++_document );
_lastKey = null;
}
/// <summary>Seed the baseline when the history is brand-new, without disturbing an existing one
/// (a restored session keeps its undo chain).</summary>
public void EnsureSeeded( string snapshot, string sourceId ) => _current ??= new Entry( snapshot, sourceId, _document );
/// <summary>Re-anchor the baseline to the ACTUAL live state after a restore, without recording an
/// entry or clearing the redo stack. Deserializing a snapshot normalizes a few fields (empty
/// id/name defaults, range clamps), so the restored level can re-serialize slightly differently
/// from the stored string — without this, the editor's poll would read that drift as a fresh edit
/// and kill the redo stack right after an undo.</summary>
public void Rebase( string snapshot, string sourceId )
{
// Re-anchoring is never a document change: keep the restored entry's document number.
_current = new Entry( snapshot, sourceId, _current?.Document ?? _document );
_lastKey = null;
}
/// <summary>Record the state after a discrete edit. No-ops only when the level AND its source file
/// are both unchanged: a document swap can re-serialize identically (re-pasting the open level), and
/// that still has to record an entry so the replaced document's file claim leaves the baseline.
/// Calling this generously is safe — a MISSED call never corrupts anything, two edits just merge
/// into one undo step. A non-null <paramref name="coalesceKey"/> merges rapid same-key edits.
/// <paramref name="newDocument"/> marks the operations that REPLACE the open document (reset,
/// paste-import, template load): those always record, and never merge into the previous entry.</summary>
public void Checkpoint( string snapshot, string sourceId, string coalesceKey = null, bool newDocument = false )
{
if ( _current is null ) { _current = new Entry( snapshot, sourceId, _document ); return; }
if ( !newDocument && snapshot == _current.Value.Json && SameSource( sourceId, _current.Value.SourceId ) ) return;
int document = newDocument ? ++_document : _current.Value.Document;
bool coalesce = !newDocument && coalesceKey is not null && coalesceKey == _lastKey && _time - _lastTime <= COALESCE_SECONDS;
if ( !coalesce )
{
_undo.Add( _current.Value );
if ( _undo.Count > MAX_ENTRIES ) _undo.RemoveAt( 0 );
}
_current = new Entry( snapshot, sourceId, document );
_redo.Clear();
_lastKey = coalesceKey;
_lastTime = _time;
}
/// <summary>Step back one entry, or null when there's nothing to undo. Any not-yet-checkpointed
/// edits are captured first so they become the redo target instead of being lost; because that
/// internal Checkpoint no-ops on an unchanged state, an undo chain never clears the redo stack.</summary>
public Entry? Undo( string snapshot, string sourceId )
{
Checkpoint( snapshot, sourceId );
if ( _undo.Count == 0 ) return null;
_redo.Add( _current.Value );
_current = _undo[^1];
_undo.RemoveAt( _undo.Count - 1 );
_lastKey = null;
return _current;
}
/// <summary>Step forward one entry, or null when there's nothing to redo. A genuinely new edit
/// since the last undo clears the redo stack via the internal Checkpoint — standard semantics.</summary>
public Entry? Redo( string snapshot, string sourceId )
{
Checkpoint( snapshot, sourceId );
if ( _redo.Count == 0 ) return null;
_undo.Add( _current.Value );
_current = _redo[^1];
_redo.RemoveAt( _redo.Count - 1 );
_lastKey = null;
return _current;
}
/// <summary>A save is not an edit, but it does rename the document: every state of the document
/// just written — the entries carrying the LIVE document number, whatever source id they had — now
/// belongs to <paramref name="to"/>, so undoing an ordinary edit after a first save still updates
/// that file. States of a different document (before a reset/import/template load) keep theirs.</summary>
public void RetagSource( string to )
{
if ( _current is not { } cur ) return;
Retag( _undo, cur.Document, to );
Retag( _redo, cur.Document, to );
if ( !SameSource( cur.SourceId, to ) ) _current = cur with { SourceId = to };
}
private static void Retag( List<Entry> list, int document, string to )
{
for ( int i = 0; i < list.Count; i++ )
if ( list[i].Document == document ) list[i] = list[i] with { SourceId = to };
}
private static bool SameSource( string a, string b )
=> string.IsNullOrEmpty( a ) ? string.IsNullOrEmpty( b ) : string.Equals( a, b, System.StringComparison.OrdinalIgnoreCase );
}