using System.Collections.Generic; using UnityEngine; #if ENABLE_INPUT_SYSTEM using UnityEngine.InputSystem; #endif public partial class MeshPainter { // --- Undo / redo: per-stroke history of the canonical maps, mirrored on every peer --- // // One stroke (mouse-down -> mouse-up) = one undo step; each entry is the full canonical map set AFTER // that edit, restored by re-seeding the backend. Index 0 is the baseline (material seed, or a // loaded/snapshotted one). // // Networked: every replica keeps the SAME history, so undo/redo replicates as a tiny command. The // owner tags each committed stroke with a monotonic CHECKPOINT ID; remotes capture history at the same // boundaries under that id. Undo/redo carries the TARGET id; a peer lacking it (a late joiner) falls // back to a snapshot. PlayerPaintSync drives this via OnHistoryCheckpoint / OnHistoryRestore. // // Memory: unchanged maps are shared by reference with the previous entry (copy-on-write) - safe // because both backends CLONE their seed input and ReadCanonical returns fresh arrays. [Header("Undo")] [Tooltip("Maximum number of undoable edits kept in memory (strokes + loads). Older edits drop off; " + "the oldest kept state is the undo floor. Higher = more undo depth but more memory (only the " + "maps a stroke changed are stored per step).")] [Min(1)] public int maxUndoSteps = 20; /// /// Fires on the owner when a stroke is committed as a new history entry, carrying its checkpoint id. /// PlayerPaintSync forwards a marker so every remote captures the same entry under the same id. No /// subscriber = no-op (single-player). /// public event System.Action OnHistoryCheckpoint; /// /// Fires on the owner when the painting is restored to a history entry (undo / redo), carrying that /// entry's checkpoint id. PlayerPaintSync forwards the id so peers reproduce the move locally (or fetch /// a snapshot if they lack that id). No subscriber = no-op (single-player). /// public event System.Action OnHistoryRestore; private List _undoStates; // index 0 = oldest kept state; null until InitUndoHistory ran private List _undoIds; // parallel checkpoint id per state (owner-assigned, peer-shared) private int _undoIndex; // the state the painter currently shows private int _nextCheckpointId; // owner: next id to assign; remotes set ids from the network private bool _strokeActive; // a stroke has painted >=1 stamp and is not yet committed to history private int _strokeDirtyMaps; // bitmask of map indices touched by the current stroke /// The checkpoint id of the state currently shown. Used as the baseline tag when snapshotting. public int CurrentCheckpointId => (_undoIds != null && _undoIndex >= 0 && _undoIndex < _undoIds.Count) ? _undoIds[_undoIndex] : 0; /// Seeds the history with the material baseline (id 0). Called once after the backend is set up. private void InitUndoHistory() { if (_seedCanonical == null) return; SetSingleBaseline(_seedCanonical, 0); _nextCheckpointId = 1; } /// /// Rebuilds the whole history to a single baseline entry holding (per-map null /// falls back to the live backend), tagged . Clears all undo/redo. Used by the /// material seed, a load (owner) and an applied snapshot (remote). Takes ownership of the passed arrays. /// private void SetSingleBaseline(Color32[][] maps, int id) { if (_undoStates == null) _undoStates = new List(Mathf.Max(2, maxUndoSteps + 1)); if (_undoIds == null) _undoIds = new List(Mathf.Max(2, maxUndoSteps + 1)); _undoStates.Clear(); _undoIds.Clear(); int n = textureSize * textureSize; var baseline = new Color32[MAP_COUNT][]; for (int mi = 0; mi < MAP_COUNT; mi++) { var s = (maps != null) ? maps[mi] : null; baseline[mi] = (s != null && s.Length == n) ? s : ReadCanonical(mi); } _undoStates.Add(baseline); _undoIds.Add(id); _undoIndex = 0; _strokeActive = false; _strokeDirtyMaps = 0; } /// Owner-only: replace the whole history with the current maps as the sole baseline (used by load). private int ResetHistoryToCurrent(Color32[][] maps) { int id = _nextCheckpointId++; SetSingleBaseline(maps, id); return id; } /// Remote-only: make the just-applied snapshot maps the sole history baseline under the authority's id. public void SetHistoryBaseline(Color32[][] maps, int baselineId) { if (!_setupValid) return; SetSingleBaseline(maps, baselineId); } /// Marks that the current stroke painted a stamp (called per committed stamp, local or replicated). private void MarkStrokePainted() { _strokeActive = true; if (paintAlbedo) _strokeDirtyMaps |= 1 << MAP_ALBEDO; if (paintMetallicSmoothness || paintOcclusion) _strokeDirtyMaps |= 1 << MAP_MSO; if (paintEmission) _strokeDirtyMaps |= 1 << MAP_EM; } /// /// Owner: commits an in-progress stroke as one undo step and announces its checkpoint id (no-op if /// nothing was painted). Remotes use RemoteCommitCheckpoint instead. /// private void EndStrokeIfActive() { if (!_strokeActive) return; // Stroke held but nothing actually painted (no active channel / no stamp landed): drop it without // consuming a checkpoint id, so the owner only ever announces ids it really holds an entry for. if (_strokeDirtyMaps == 0) { _strokeActive = false; return; } int id = _nextCheckpointId++; CaptureUndoState(_strokeDirtyMaps, id); _strokeActive = false; _strokeDirtyMaps = 0; OnHistoryCheckpoint?.Invoke(id); } /// /// Remote: commits the stamps replayed since the last checkpoint as one undo step under the owner's id, /// keeping this peer's history in lockstep. Always records the id - even if (rarely) no stamp landed /// here for the stroke - so every replica's history stays 1:1 aligned with the owner's and a later /// undo/redo to this id reproduces locally instead of forcing a snapshot fallback. /// public void RemoteCommitCheckpoint(int id) { if (!_setupValid) return; CaptureUndoState(_strokeDirtyMaps, id, forceEntry: true); _strokeActive = false; _strokeDirtyMaps = 0; } /// /// Snapshots the current maps as a new history entry under , dropping any redo /// branch. With an entry is added even when nothing changed (it then /// shares every map with the previous entry via COW) - used by remote checkpoints to keep ids aligned. /// private void CaptureUndoState(int dirtyMaps, int id, bool forceEntry = false) { if (_undoStates == null) return; if (dirtyMaps == 0 && !forceEntry) return; // A fresh edit invalidates everything ahead of the cursor (the redo branch). if (_undoIndex < _undoStates.Count - 1) { int from = _undoIndex + 1; int n = _undoStates.Count - from; _undoStates.RemoveRange(from, n); _undoIds.RemoveRange(from, n); } Color32[][] prev = _undoStates[_undoIndex]; var state = new Color32[MAP_COUNT][]; for (int mi = 0; mi < MAP_COUNT; mi++) { bool changed = (dirtyMaps & (1 << mi)) != 0; state[mi] = changed ? ReadCanonical(mi) : prev[mi]; // COW: share maps the stroke didn't touch } _undoStates.Add(state); _undoIds.Add(id); _undoIndex = _undoStates.Count - 1; // Bound memory: keep at most maxUndoSteps edits beyond the oldest kept state. int maxEntries = Mathf.Max(1, maxUndoSteps) + 1; while (_undoStates.Count > maxEntries) { _undoStates.RemoveAt(0); _undoIds.RemoveAt(0); _undoIndex--; } } /// Owner: steps back one edit and announces the restore. Returns false if already at the floor. public bool Undo() { if (_undoStates == null || _undoIndex <= 0) return false; _undoIndex--; RestoreCurrentState(); return true; } /// Owner: steps forward one edit (after an undo). Returns false if already at the newest state. public bool Redo() { if (_undoStates == null || _undoIndex >= _undoStates.Count - 1) return false; _undoIndex++; RestoreCurrentState(); return true; } private void RestoreCurrentState() { RestoreEntry(_undoIndex); OnHistoryRestore?.Invoke(_undoIds[_undoIndex]); // owner -> peers reproduce the move by id } /// Shows history entry : re-seeds the backend and clears any in-progress stroke. private void RestoreEntry(int idx) { _undoIndex = idx; ReseedCanonical(_undoStates[idx]); _strokeActive = false; _strokeDirtyMaps = 0; } /// /// Remote: restores to the history entry tagged . Returns false (and does nothing) /// if this peer doesn't hold that id - the caller then falls back to fetching a snapshot. /// public bool RemoteRestoreTo(int id) { if (_undoStates == null || _undoIds == null) return false; int idx = _undoIds.IndexOf(id); if (idx < 0) return false; RestoreEntry(idx); return true; } /// Reads Ctrl+Z / Ctrl+Y this frame. Modifier-gated so plain Z/Y never trigger it. private void GetUndoRedoInput(out bool undo, out bool redo) { undo = false; redo = false; if (TypingGuard.IsTyping) return; // let Ctrl+Z/Y edit text instead of the painting if (!InputUtil.CtrlHeld()) return; #if ENABLE_INPUT_SYSTEM var kb = Keyboard.current; if (kb != null) { undo = kb.zKey.wasPressedThisFrame; redo = kb.yKey.wasPressedThisFrame; return; } #endif #if ENABLE_LEGACY_INPUT_MANAGER undo = Input.GetKeyDown(KeyCode.Z); redo = Input.GetKeyDown(KeyCode.Y); #endif } }