diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ChartEditorDataModule.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ChartEditorDataModule.cs index 50f26a3ca..54a3e176c 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ChartEditorDataModule.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ChartEditorDataModule.cs @@ -10,7 +10,6 @@ public class ChartEditorDataModule : BaseDataModule { public CommandStack CommandStack { get; private set; } = null!; - public override void OnInit() { } diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ChartEditorSceneRoot.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ChartEditorSceneRoot.cs index a663cd391..609222b8c 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ChartEditorSceneRoot.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ChartEditorSceneRoot.cs @@ -61,6 +61,9 @@ public void InitSceneRoot() ChartPackData chartPackData; ChartData chartData; + // 是否为新建谱面 + bool isNewChart = false; + if (chartModule.SelectedRuntimeChartPack is null) { // 创建新谱包和谱面 @@ -77,6 +80,7 @@ public void InitSceneRoot() chartPackData = new ChartPackData(randomName, bpmGroup: bpmGroup, chartMetaDatas: new List { chartMetaData }); chartMetadataIndex = 0; + isNewChart = true; } else if (chartModule.ChartData is null) { @@ -92,6 +96,7 @@ public void InitSceneRoot() chartPackData.ChartMetaDatas.Add(chartMetaData); chartMetadataIndex = chartPackData.ChartMetaDatas.Count - 1; + isNewChart = true; } else { @@ -110,6 +115,7 @@ public void InitSceneRoot() chartMetadataIndex, chartPackData, chartData, + isNewChart, musicManager, noteAudioManager, shortcutManager, diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Command/CommandStack.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Command/CommandStack.cs index 88587bcb6..cb8105516 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Command/CommandStack.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Command/CommandStack.cs @@ -1,6 +1,7 @@ #nullable enable using System.Collections.Generic; +using R3; using UnityEngine; namespace CyanStars.Gameplay.ChartEditor.Command @@ -11,19 +12,75 @@ namespace CyanStars.Gameplay.ChartEditor.Command /// 使用 List 管理命令实例,以提供撤销重做功能 public class CommandStack : MonoBehaviour { - private readonly List CommandHistory = new List(); + private class CommandEntry + { + public readonly ICommand Command; + public readonly bool AffectsSavedData; + + public CommandEntry(ICommand command, bool affectsSavedData) + { + Command = command; + AffectsSavedData = affectsSavedData; + } + } + + private readonly List CommandHistory = new List(); + + // 历史记录上限,超出后从最旧开始丢弃,防止内存溢出 + private const int MaxHistoryCount = 100; // 指向当前"最后一条已执行"的命令的索引 // -1 表示没有任何命令被执行(初始状态或全部撤销) private int currentCommandIndex = -1; + // 保存边界:最近一次保存时数据状态对应的数据命令条目。 + private CommandEntry? savedDataEntry = null; + + // 旁路修改(不经过命令栈的数据写入)置脏标记,MarkSaved 时清除 + private bool forcedDirty = false; + + private readonly ReactiveProperty hasUnsavedChanges = new ReactiveProperty(false); + + /// + /// 是否存在未保存数据(订阅时立即推送当前值) + /// + public ReadOnlyReactiveProperty HasUnsavedChanges => hasUnsavedChanges; + + /// + /// 是否正在回放命令(Execute/Undo/Redo,tracked 类型据此跳过命令记录,防止回放再生成命令) + /// + public bool IsReplaying { get; private set; } = false; + + /// + /// 每次进入制谱器会话时初始化 + /// + /// 会话初始是否存在未保存数据(新建谱面为 true,加载已有谱面为 false) + public void Init(bool initialHasUnsavedChanges) + { + CommandHistory.Clear(); + currentCommandIndex = -1; + savedDataEntry = null; + forcedDirty = initialHasUnsavedChanges; + UpdateUnsavedState(); + } + /// /// 执行新命令 /// - public void ExecuteCommand(ICommand command) + /// 要执行的命令 + /// 该命令是否修改持久化数据。纯视图/选中状态的命令传 false,不参与脏判定 + public void ExecuteCommand(ICommand command, bool affectsSavedData = true) { // TODO: 用事件驱动以替换当前的命令调用,以避免 View/MonoBehaviour 的内存泄漏 - command.Execute(); + IsReplaying = true; + try + { + command.Execute(); + } + finally + { + IsReplaying = false; + } // 如果当前索引不是在列表末尾,需要丢弃当前位置之后的所有旧历史 if (currentCommandIndex < CommandHistory.Count - 1) @@ -33,10 +90,19 @@ public void ExecuteCommand(ICommand command) CommandHistory.RemoveRange(removeStartIndex, countToRemove); } - CommandHistory.Add(command); + CommandHistory.Add(new CommandEntry(command, affectsSavedData)); currentCommandIndex++; - // TODO: 可选添加最大历史记录限制,防止内存溢出 + // 超出历史上限时丢弃最旧的命令。 + // 若保存边界条目被丢弃,数据不可能再与磁盘一致,引用比较会保持脏状态直到下次保存 + if (CommandHistory.Count > MaxHistoryCount) + { + int overflowCount = CommandHistory.Count - MaxHistoryCount; + CommandHistory.RemoveRange(0, overflowCount); + currentCommandIndex -= overflowCount; + } + + UpdateUnsavedState(); } /// @@ -51,8 +117,18 @@ public void Undo() return; } - CommandHistory[currentCommandIndex].Undo(); + IsReplaying = true; + try + { + CommandHistory[currentCommandIndex].Command.Undo(); + } + finally + { + IsReplaying = false; + } + currentCommandIndex--; + UpdateUnsavedState(); } /// @@ -68,16 +144,69 @@ public void Redo() } currentCommandIndex++; - CommandHistory[currentCommandIndex].Execute(); + IsReplaying = true; + try + { + CommandHistory[currentCommandIndex].Command.Execute(); + } + finally + { + IsReplaying = false; + } + + UpdateUnsavedState(); } + // /// + // /// 清空历史记录 + // /// + // public void Clear() + // { + // CommandHistory.Clear(); + // currentCommandIndex = -1; + // savedDataEntry = null; + // UpdateUnsavedState(); + // } + /// - /// 清空历史记录 + /// 标记当前数据为已保存(保存成功后调用) /// - public void Clear() + public void MarkSaved() { - CommandHistory.Clear(); - currentCommandIndex = -1; + forcedDirty = false; + savedDataEntry = GetCurrentDataEntry(); + UpdateUnsavedState(); + } + + /// + /// 强制标记为有未保存数据(不经过命令栈的旁路数据修改使用) + /// + public void MarkDirty() + { + forcedDirty = true; + UpdateUnsavedState(); + } + + /// + /// 当前数据状态对应的最近一条数据命令条目(从当前索引向下找),无则为 null + /// + private CommandEntry? GetCurrentDataEntry() + { + for (int i = currentCommandIndex; i >= 0; i--) + { + if (CommandHistory[i].AffectsSavedData) + return CommandHistory[i]; + } + + return null; + } + + /// + /// 根据当前数据命令条目与保存边界条目是否相同来重算未保存状态 + /// + private void UpdateUnsavedState() + { + hasUnsavedChanges.Value = forcedDirty || savedDataEntry != GetCurrentDataEntry(); } } } diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Command/CommandStackExtend.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Command/CommandStackExtend.cs index 31c1e94ea..cd88f0343 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Command/CommandStackExtend.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Command/CommandStackExtend.cs @@ -9,10 +9,14 @@ namespace CyanStars.Gameplay.ChartEditor.Command /// public static class CommandStackExtend { - public static void ExecuteCommand(this CommandStack commandStack, Action? executeAction, Action? undoAction) - { - commandStack.ExecuteCommand(new DelegateCommand(executeAction, undoAction)); - } + public static void ExecuteCommand( + this CommandStack commandStack, + Action? executeAction, + Action? undoAction, + bool affectsSavedData = true + ) + => commandStack.ExecuteCommand(new DelegateCommand(executeAction, undoAction), affectsSavedData); + private class DelegateCommand : ICommand { diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Management/MvvmBindManager.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Management/MvvmBindManager.cs index e1c83f6d1..bcd640fb6 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Management/MvvmBindManager.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Management/MvvmBindManager.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using CyanStars.Chart; +using CyanStars.Framework; using CyanStars.Gameplay.ChartEditor.Model; using CyanStars.Gameplay.ChartEditor.View; using CyanStars.Gameplay.ChartEditor.ViewModel; @@ -62,17 +63,22 @@ public class MvvmBindManager : MonoBehaviour /// /// 注意:由于引用关系,制谱器会修改传入的谱包和谱面实例内的数据。请先深拷贝一个谱包和谱面,再调用制谱器初始化 public void StartBind(string workspacePath, - int chartMetadataIndex, - ChartPackData chartPackData, - ChartData chartData, - ChartEditorMusicManager musicManager, - ChartEditorNoteAudioManager chartEditorNoteAudioManager, - ShortcutManager shortcutManager, - ChartEditorPlayerPrefsManager playerPrefsManager) + int chartMetadataIndex, + ChartPackData chartPackData, + ChartData chartData, + bool initialHasUnsavedChanges, + ChartEditorMusicManager musicManager, + ChartEditorNoteAudioManager chartEditorNoteAudioManager, + ShortcutManager shortcutManager, + ChartEditorPlayerPrefsManager playerPrefsManager) { - // TODO: 为 Model 实现 IDispose,以进一步管理生命周期 - ChartEditorModel model = - new ChartEditorModel(workspacePath, chartMetadataIndex, chartPackData, chartData); + var commandStack = GameRoot.GetDataModule().CommandStack; + + // 初始化命令栈,开始追踪未保存数据 + commandStack.Init(initialHasUnsavedChanges); + + var model = + new ChartEditorModel(workspacePath, chartMetadataIndex, chartPackData, chartData, commandStack); // 初始化一些 Manager musicManager.Init(model); diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/ChartDataEditorModel.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/ChartDataEditorModel.cs index 28766d0db..f999e3e88 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/ChartDataEditorModel.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/ChartDataEditorModel.cs @@ -3,6 +3,7 @@ using System.Diagnostics.Contracts; using System.Linq; using CyanStars.Chart; +using CyanStars.Gameplay.ChartEditor.Command; using ObservableCollections; using R3; @@ -15,16 +16,16 @@ public class ChartDataEditorModel { private readonly ChartData ChartData; - public readonly ReactiveProperty ReadyBeat; + public readonly TrackedReactiveProperty ReadyBeat; public readonly ObservableList SpeedGroupDatas; public readonly ObservableList Notes; public readonly ObservableList TrackDatas; - public ChartDataEditorModel(ChartData chartData) + public ChartDataEditorModel(ChartData chartData, CommandStack commandStack) { ChartData = chartData; - ReadyBeat = new ReactiveProperty(chartData.ReadyBeat); + ReadyBeat = new TrackedReactiveProperty(commandStack, chartData.ReadyBeat); SpeedGroupDatas = new ObservableList(chartData.SpeedGroupDatas); Notes = new ObservableList(chartData.Notes); TrackDatas = new ObservableList(chartData.TrackDatas); diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/ChartEditorModel.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/ChartEditorModel.cs index 4c886475c..8b28cdbf8 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/ChartEditorModel.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/ChartEditorModel.cs @@ -2,6 +2,7 @@ using CatAsset.Runtime; using CyanStars.Chart; +using CyanStars.Gameplay.ChartEditor.Command; using R3; using UnityEngine; @@ -97,13 +98,14 @@ public class ChartEditorModel public ChartEditorModel(string workspacePath, int chartMetaDataIndex, ChartPackData chartPackData, - ChartData chartData) + ChartData chartData, + CommandStack commandStack) { WorkspacePath = workspacePath; ChartMetaDataIndex = chartMetaDataIndex; - ChartPackData = new ReactiveProperty(new ChartPackDataEditorModel(chartPackData)); - ChartData = new ReactiveProperty(new ChartDataEditorModel(chartData)); + ChartPackData = new ReactiveProperty(new ChartPackDataEditorModel(chartPackData, commandStack)); + ChartData = new ReactiveProperty(new ChartDataEditorModel(chartData, commandStack)); } } } diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/ChartMetaDataEditorModel.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/ChartMetaDataEditorModel.cs index b841ccf3b..550ec7ff8 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/ChartMetaDataEditorModel.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/ChartMetaDataEditorModel.cs @@ -2,6 +2,7 @@ using System.Diagnostics.Contracts; using CyanStars.Chart; +using CyanStars.Gameplay.ChartEditor.Command; using R3; namespace CyanStars.Gameplay.ChartEditor.Model @@ -12,13 +13,13 @@ namespace CyanStars.Gameplay.ChartEditor.Model public class ChartMetaDataEditorModel { public readonly ReactiveProperty FilePath; - public readonly ReactiveProperty Difficulty; + public readonly TrackedReactiveProperty Difficulty; public readonly ReactiveProperty ChartHash; - public ChartMetaDataEditorModel(ChartMetaData chartMetaData) + public ChartMetaDataEditorModel(ChartMetaData chartMetaData, CommandStack commandStack) { FilePath = new ReactiveProperty(chartMetaData.FilePath); - Difficulty = new ReactiveProperty(chartMetaData.Difficulty); + Difficulty = new TrackedReactiveProperty(commandStack, chartMetaData.Difficulty); ChartHash = new ReactiveProperty(chartMetaData.ChartHash); } diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/ChartPackDataEditorModel.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/ChartPackDataEditorModel.cs index 0b4c059a1..6786102b4 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/ChartPackDataEditorModel.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/ChartPackDataEditorModel.cs @@ -3,6 +3,7 @@ using System.Diagnostics.Contracts; using System.Linq; using CyanStars.Chart; +using CyanStars.Gameplay.ChartEditor.Command; using ObservableCollections; using R3; using UnityEngine; @@ -15,35 +16,35 @@ namespace CyanStars.Gameplay.ChartEditor.Model public class ChartPackDataEditorModel { public readonly ReactiveProperty DataVersion; - public readonly ReactiveProperty Title; - public readonly ReactiveProperty ChartPackInfo; + public readonly TrackedReactiveProperty Title; + public readonly TrackedReactiveProperty ChartPackInfo; public readonly ObservableList MusicVersions; public readonly ObservableList BpmGroup; - public readonly ReactiveProperty MusicPreviewStartBeat; - public readonly ReactiveProperty MusicPreviewEndBeat; + public readonly TrackedReactiveProperty MusicPreviewStartBeat; + public readonly TrackedReactiveProperty MusicPreviewEndBeat; public readonly ReactiveProperty CoverFilePath; public readonly ReactiveProperty CropStartPositionPercent; public readonly ReactiveProperty CropHeightPercent; public readonly ObservableList ChartMetaDatas; - public ChartPackDataEditorModel(ChartPackData chartPackData) + public ChartPackDataEditorModel(ChartPackData chartPackData, CommandStack commandStack) { DataVersion = new ReactiveProperty(chartPackData.DataVersion); - Title = new ReactiveProperty(chartPackData.Title); - ChartPackInfo = new ReactiveProperty(chartPackData.ChartPackInfo); + Title = new TrackedReactiveProperty(commandStack, chartPackData.Title); + ChartPackInfo = new TrackedReactiveProperty(commandStack, chartPackData.ChartPackInfo); MusicVersions = new ObservableList( chartPackData.MusicVersionDatas - .Select(static v => new MusicVersionDataEditorModel(v)) + .Select(v => new MusicVersionDataEditorModel(v, commandStack)) ); BpmGroup = new ObservableList(chartPackData.BpmGroup); - MusicPreviewStartBeat = new ReactiveProperty(chartPackData.MusicPreviewStartBeat); - MusicPreviewEndBeat = new ReactiveProperty(chartPackData.MusicPreviewEndBeat); + MusicPreviewStartBeat = new TrackedReactiveProperty(commandStack, chartPackData.MusicPreviewStartBeat); + MusicPreviewEndBeat = new TrackedReactiveProperty(commandStack, chartPackData.MusicPreviewEndBeat); CoverFilePath = new ReactiveProperty(chartPackData.CoverFilePath); CropStartPositionPercent = new ReactiveProperty(chartPackData.CropStartPositionPercent); CropHeightPercent = new ReactiveProperty(chartPackData.CropHeightPercent); ChartMetaDatas = new ObservableList( chartPackData.ChartMetaDatas - .Select(static d => new ChartMetaDataEditorModel(d)) + .Select(d => new ChartMetaDataEditorModel(d, commandStack)) ); } diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/MusicVersionDataEditorModel.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/MusicVersionDataEditorModel.cs index 34cf496ae..4cb8ec25e 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/MusicVersionDataEditorModel.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/MusicVersionDataEditorModel.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics.Contracts; using CyanStars.Chart; +using CyanStars.Gameplay.ChartEditor.Command; using ObservableCollections; using R3; @@ -15,17 +16,17 @@ public class MusicVersionDataEditorModel { private readonly MusicVersionData MusicVersionData; - public readonly ReactiveProperty VersionTitle; + public readonly TrackedReactiveProperty VersionTitle; public readonly ReactiveProperty AudioFilePath; - public readonly ReactiveProperty Offset; + public readonly TrackedReactiveProperty Offset; - public MusicVersionDataEditorModel(MusicVersionData musicVersionData) + public MusicVersionDataEditorModel(MusicVersionData musicVersionData, CommandStack commandStack) { MusicVersionData = musicVersionData; - VersionTitle = new ReactiveProperty(musicVersionData.VersionTitle); + VersionTitle = new TrackedReactiveProperty(commandStack, musicVersionData.VersionTitle); AudioFilePath = new ReactiveProperty(musicVersionData.AudioFilePath); - Offset = new ReactiveProperty(musicVersionData.Offset); + Offset = new TrackedReactiveProperty(commandStack, musicVersionData.Offset); } /// diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/TrackedReactiveProperty.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/TrackedReactiveProperty.cs new file mode 100644 index 000000000..bfeca5c9b --- /dev/null +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/TrackedReactiveProperty.cs @@ -0,0 +1,40 @@ +#nullable enable + +using System.Collections.Generic; +using CyanStars.Gameplay.ChartEditor.Command; +using R3; + +namespace CyanStars.Gameplay.ChartEditor.Model +{ + /// + /// 带命令记录的响应式属性:值变化时自动生成撤销命令并压入 CommandStack + /// + public class TrackedReactiveProperty : ReactiveProperty + { + private readonly CommandStack CommandStack; + + public TrackedReactiveProperty(CommandStack commandStack, T initialValue) + : base(initialValue) + { + CommandStack = commandStack; + } + + public override T Value + { + set + { + var oldValue = CurrentValue; + base.Value = value; + + // 基类构造期间字段尚未赋值,跳过记录;回放中或值未变化时也不生成命令 + if (CommandStack == null || CommandStack.IsReplaying || EqualityComparer.Default.Equals(oldValue, value)) + return; + + CommandStack.ExecuteCommand( + () => base.Value = value, + () => base.Value = oldValue + ); + } + } + } +} diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/TrackedReactiveProperty.cs.meta b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/TrackedReactiveProperty.cs.meta new file mode 100644 index 000000000..30324a1a4 --- /dev/null +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/Model/TrackedReactiveProperty.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 866f80d7bf13b8040b888b3beb46ade4 \ No newline at end of file diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/View/BasePopupView.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/View/BasePopupView.cs index 2312ee979..5c8806f24 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/View/BasePopupView.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/View/BasePopupView.cs @@ -80,7 +80,8 @@ public void SetCanvasVisibility(bool visible) commandStack.ExecuteCommand( () => CanvasVisibility.Value = visible, - () => CanvasVisibility.Value = !visible + () => CanvasVisibility.Value = !visible, + affectsSavedData: false ); } diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/View/MenuButtonsView.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/View/MenuButtonsView.cs index 39d15b210..768c2ab2f 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/View/MenuButtonsView.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/View/MenuButtonsView.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using CyanStars.Gameplay.ChartEditor.ViewModel; +using CyanStars.Utils.SelectableUI; using DG.Tweening; using R3; using UnityEngine; @@ -76,6 +77,7 @@ public class MenuButtonsView : BaseView private readonly List ShortcutListeners = new(); private Canvas functionCanvas = null!; + private SelectableStateObserver? saveButtonSelectableStateObserver; private void OnEnable() @@ -90,6 +92,7 @@ public override void Bind(MenuButtonsViewModel targetViewModel) base.Bind(targetViewModel); functionCanvas = functionCanvasGroup.GetComponent(); + saveButtonSelectableStateObserver = saveButton.GetComponent(); FunctionCanvasVisibility .Subscribe(isVisible => @@ -132,6 +135,15 @@ public override void Bind(MenuButtonsViewModel targetViewModel) .OnClickAsObservable() .Subscribe(_ => OnSaveRequested()) .AddTo(this); + ViewModel.HasUnsavedChanges + .Subscribe(hasUnsavedChanges => + { + if (saveButtonSelectableStateObserver != null) + saveButtonSelectableStateObserver.SetInteractable(hasUnsavedChanges); + else + saveButton.interactable = hasUnsavedChanges; + }) + .AddTo(this); // testButton ... undoButton .OnClickAsObservable() diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/BpmGroupViewModel.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/BpmGroupViewModel.cs index 34f7cf66d..12036bba9 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/BpmGroupViewModel.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/BpmGroupViewModel.cs @@ -59,7 +59,8 @@ public void SelectBpmItem(BpmGroupItem? newItem) var oldItem = selectedBpmItem.Value; CommandStack.ExecuteCommand( () => selectedBpmItem.Value = newItem, - () => selectedBpmItem.Value = oldItem + () => selectedBpmItem.Value = oldItem, + affectsSavedData: false ); } diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/ChartDataViewModel.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/ChartDataViewModel.cs index 3c72f7361..4baeca38a 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/ChartDataViewModel.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/ChartDataViewModel.cs @@ -1,7 +1,6 @@ #nullable enable using CyanStars.Chart; -using CyanStars.Gameplay.ChartEditor.Command; using CyanStars.Gameplay.ChartEditor.Model; using R3; @@ -35,15 +34,8 @@ public ChartDataViewModel(ChartEditorModel model) public void SetChartDifficulty(ChartDifficulty? newDifficulty) { - var oldDifficulty = ChartDifficulty.CurrentValue; - - if (newDifficulty == oldDifficulty) - return; - - CommandStack.ExecuteCommand( - () => MetaData.Difficulty.Value = newDifficulty, - () => MetaData.Difficulty.Value = oldDifficulty - ); + // Difficulty 为 tracked 属性,等值赋值自动忽略,值变化则自动生成撤销命令 + MetaData.Difficulty.Value = newDifficulty; } public void SetReadyBeatCount(string newBeatCount) @@ -54,14 +46,7 @@ public void SetReadyBeatCount(string newBeatCount) return; } - var oldBeatIntCount = ChartData.ReadyBeat.Value; - if (newBeatCountInt == oldBeatIntCount) - return; - - CommandStack.ExecuteCommand( - () => ChartData.ReadyBeat.Value = newBeatCountInt, - () => ChartData.ReadyBeat.Value = oldBeatIntCount - ); + ChartData.ReadyBeat.Value = newBeatCountInt; } } } diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/ChartPackDataCoverViewModel.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/ChartPackDataCoverViewModel.cs index 39f950ab7..9252c54c5 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/ChartPackDataCoverViewModel.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/ChartPackDataCoverViewModel.cs @@ -146,7 +146,10 @@ private void GetDefaultCoverCropData(Sprite sprite, out Vector2 startPosPercent, public void OpenCoverBrowser() { - GameRoot.File.OpenLoadFilePathBrowser(SetCoverFilePath, title: "打开曲绘", filters: new[] { GameRoot.File.SpriteFilter }); + GameRoot.File.OpenLoadFilePathBrowser(SetCoverFilePath, title: "打开曲绘", filters: new[] + { + GameRoot.File.SpriteFilter + }); } private void SetCoverFilePath(string newOriginFilePath) @@ -233,7 +236,9 @@ public void CommitCropData() Vector2? newCropStartPos = Model.ChartPackData.CurrentValue.CropStartPositionPercent.Value; float? newCropHeight = Model.ChartPackData.CurrentValue.CropHeightPercent.Value; - if (newCropStartPos == recordedCropStartPosPercent && newCropHeight == recordedCropHeightPercent) + if (Mathf.Approximately(newCropStartPos?.x ?? 0f, recordedCropStartPosPercent?.x ?? 0f) && + Mathf.Approximately(newCropStartPos?.y ?? 0f, recordedCropStartPosPercent?.y ?? 0f) && + Mathf.Approximately(newCropHeight ?? 0f, recordedCropHeightPercent ?? 0f)) return; CommandStack.ExecuteCommand( @@ -351,8 +356,15 @@ public void OnHandlerDragging(CoverCropHandlerType handlerType, Vector2 percentP float newCropHeightPercent = newCropHeightPixel / coverPixelSize.y; // 实时更新 Model 数据以实现实时预览,不生成命令 + bool changed = !Mathf.Approximately(cropData.CropStartPositionPercent.Value?.x ?? 0f, newCropStartPercent.x) || + !Mathf.Approximately(cropData.CropStartPositionPercent.Value?.y ?? 0f, newCropStartPercent.y) || + !Mathf.Approximately(cropData.CropHeightPercent.Value ?? 0f, newCropHeightPercent); cropData.CropStartPositionPercent.Value = newCropStartPercent; cropData.CropHeightPercent.Value = newCropHeightPercent; + + // 拖拽预览绕过命令栈直接写 Model,手动标记脏状态 + if (changed) + CommandStack.MarkDirty(); } /// @@ -388,10 +400,14 @@ public void OnFrameDragging(Vector2 deltaRatio) targetPixelPos.x = Mathf.Clamp(targetPixelPos.x, 0f, maxX); targetPixelPos.y = Mathf.Clamp(targetPixelPos.y, 0f, maxY); - if (targetPixelPos != currentStartPixel) + if (!Mathf.Approximately(targetPixelPos.x, currentStartPixel.x) || + !Mathf.Approximately(targetPixelPos.y, currentStartPixel.y)) { Vector2 targetPercentPos = new Vector2(targetPixelPos.x / imgW, targetPixelPos.y / imgH); cropData.CropStartPositionPercent.Value = targetPercentPos; + + // 拖拽预览绕过命令栈直接写 Model,手动标记脏状态 + CommandStack.MarkDirty(); } } diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/ChartPackDataViewModel.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/ChartPackDataViewModel.cs index dba586b19..d00d1c761 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/ChartPackDataViewModel.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/ChartPackDataViewModel.cs @@ -5,12 +5,10 @@ using System.IO; using CyanStars.Chart; using CyanStars.Framework; -using CyanStars.Gameplay.ChartEditor.Command; using CyanStars.Gameplay.ChartEditor.Management; using CyanStars.Gameplay.ChartEditor.Model; using CyanStars.Gameplay.ChartEditor.View; using CyanStars.Utils; -using ObservableCollections; using R3; using UnityEngine; @@ -76,14 +74,8 @@ public ChartPackDataViewModel(ChartEditorModel model) public void SetChartPackTitle(string newTitle) { - string oldTitle = Model.ChartPackData.CurrentValue.Title.Value; - if (newTitle == oldTitle) - return; - - CommandStack.ExecuteCommand( - () => Model.ChartPackData.CurrentValue.Title.Value = newTitle, - () => Model.ChartPackData.CurrentValue.Title.Value = oldTitle - ); + // Title 为 tracked 属性,等值赋值自动忽略,值变化则自动生成撤销命令 + Model.ChartPackData.CurrentValue.Title.Value = newTitle; } public void SetPreviewStartBeat(Beat newBeat) @@ -94,15 +86,7 @@ public void SetPreviewStartBeat(Beat newBeat) return; } - var oldBeat = Model.ChartPackData.CurrentValue.MusicPreviewStartBeat.Value; - - if (newBeat == oldBeat) - return; - - CommandStack.ExecuteCommand( - () => Model.ChartPackData.CurrentValue.MusicPreviewStartBeat.Value = newBeat, - () => Model.ChartPackData.CurrentValue.MusicPreviewStartBeat.Value = oldBeat - ); + Model.ChartPackData.CurrentValue.MusicPreviewStartBeat.Value = newBeat; } public void SetPreviewEndBeat(Beat newBeat) @@ -113,25 +97,13 @@ public void SetPreviewEndBeat(Beat newBeat) return; } - var oldBeat = Model.ChartPackData.CurrentValue.MusicPreviewEndBeat.Value; - - if (newBeat == oldBeat) - return; - - CommandStack.ExecuteCommand( - () => Model.ChartPackData.CurrentValue.MusicPreviewEndBeat.Value = newBeat, - () => Model.ChartPackData.CurrentValue.MusicPreviewEndBeat.Value = oldBeat - ); + Model.ChartPackData.CurrentValue.MusicPreviewEndBeat.Value = newBeat; } public void UpdateInfo(string newText) { // TODO: 实时更新字段 + 一段时间停止输入或失焦时压入 CommandStack - var oldText = Model.ChartPackData.CurrentValue.ChartPackInfo.CurrentValue; - CommandStack.ExecuteCommand( - () => Model.ChartPackData.CurrentValue.ChartPackInfo.Value = newText, - () => Model.ChartPackData.CurrentValue.ChartPackInfo.Value = oldText - ); + Model.ChartPackData.CurrentValue.ChartPackInfo.Value = newText; } public void ExportChartPack() diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/MenuButtonsViewModel.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/MenuButtonsViewModel.cs index 611ece4fe..31ce5f9fa 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/MenuButtonsViewModel.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/MenuButtonsViewModel.cs @@ -16,29 +16,28 @@ public MenuButtonsViewModel(ChartEditorModel model) { } - public void ExitChartEditor() - { - GameRoot.ChangeProcedure(); - } + public void ExitChartEditor() => GameRoot.ChangeProcedure(); - public void Undo() - { - base.CommandStack.Undo(); - } + public void Undo() => base.CommandStack.Undo(); + public void Redo() => base.CommandStack.Redo(); - public void Redo() - { - base.CommandStack.Redo(); - } + /// + /// 是否存在未保存数据 + /// + public ReadOnlyReactiveProperty HasUnsavedChanges => CommandStack.HasUnsavedChanges; public void SaveFileToDisk() { - ChartEditorFileManager.SaveChartAndAssetsToDisk( + bool isSaveSuccess = ChartEditorFileManager.SaveChartAndAssetsToDisk( Model.WorkspacePath, Model.ChartMetaDataIndex, Model.ChartPackData.CurrentValue, Model.ChartData.CurrentValue ); + + // 只有保存成功才标记为已保存,失败保持未保存状态便于重试 + if (isSaveSuccess) + CommandStack.MarkSaved(); } } } diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/MusicVersionViewModel.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/MusicVersionViewModel.cs index c12ed10fa..e2da2fbbc 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/MusicVersionViewModel.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/MusicVersionViewModel.cs @@ -96,15 +96,17 @@ public void SelectEditingMusicVersionData(MusicVersionDataEditorModel? musicVers return; var oldValue = selectedMusicVersionData.CurrentValue; + // 纯选中状态变化,不影响持久化数据,不参与脏判定 CommandStack.ExecuteCommand( () => selectedMusicVersionData.Value = musicVersionData, - () => selectedMusicVersionData.Value = oldValue + () => selectedMusicVersionData.Value = oldValue, + affectsSavedData: false ); } public void AddMusicVersionItem() { - var newMusicVersionData = new MusicVersionDataEditorModel(new MusicVersionData("新音乐版本")); + var newMusicVersionData = new MusicVersionDataEditorModel(new MusicVersionData("新音乐版本"), CommandStack); CommandStack.ExecuteCommand( () => Model.ChartPackData.CurrentValue.MusicVersions.Add(newMusicVersionData), () => Model.ChartPackData.CurrentValue.MusicVersions.Remove(newMusicVersionData) @@ -172,13 +174,8 @@ public void SetTitle(string newTitle) if (SelectedMusicVersionData.CurrentValue == null) throw new InvalidOperationException("按设计,不允许在没有选中音乐版本数据的情况下设置标题。"); - var oldTitle = SelectedMusicVersionData.CurrentValue!.VersionTitle.Value; - if (oldTitle == newTitle) - return; - CommandStack.ExecuteCommand( - () => SelectedMusicVersionData.CurrentValue!.VersionTitle.Value = newTitle, - () => SelectedMusicVersionData.CurrentValue!.VersionTitle.Value = oldTitle - ); + // VersionTitle 为 tracked 属性,等值赋值自动忽略,值变化则自动生成撤销命令 + SelectedMusicVersionData.CurrentValue!.VersionTitle.Value = newTitle; } public void ImportAudioFile() @@ -261,10 +258,8 @@ public void MinusOffset() if (SelectedMusicVersionData.CurrentValue == null) throw new InvalidOperationException("按设计,不允许在没有选中音乐版本数据的情况下设置偏移量。"); - CommandStack.ExecuteCommand( - () => SelectedMusicVersionData.CurrentValue!.Offset.Value -= AddOffsetStep, - () => SelectedMusicVersionData.CurrentValue!.Offset.Value += AddOffsetStep - ); + // Offset 为 tracked 属性,值变化则自动生成撤销命令 + SelectedMusicVersionData.CurrentValue!.Offset.Value -= AddOffsetStep; } public void SetOffset(string text) @@ -278,13 +273,7 @@ public void SetOffset(string text) return; } - int oldValue = SelectedMusicVersionData.CurrentValue!.Offset.Value; - if (oldValue == newValue) - return; - CommandStack.ExecuteCommand( - () => SelectedMusicVersionData.CurrentValue!.Offset.Value = newValue, - () => SelectedMusicVersionData.CurrentValue!.Offset.Value = oldValue - ); + SelectedMusicVersionData.CurrentValue!.Offset.Value = newValue; } public void AddOffset() @@ -292,10 +281,7 @@ public void AddOffset() if (SelectedMusicVersionData.CurrentValue == null) throw new InvalidOperationException("按设计,不允许在没有选中音乐版本数据的情况下设置偏移量。"); - CommandStack.ExecuteCommand( - () => SelectedMusicVersionData.CurrentValue!.Offset.Value += AddOffsetStep, - () => SelectedMusicVersionData.CurrentValue!.Offset.Value -= AddOffsetStep - ); + SelectedMusicVersionData.CurrentValue!.Offset.Value += AddOffsetStep; } public void TestOffset() @@ -340,7 +326,8 @@ public void CloneItem() SelectedMusicVersionData.CurrentValue.VersionTitle.Value, SelectedMusicVersionData.CurrentValue.AudioFilePath.Value, SelectedMusicVersionData.CurrentValue.Offset.Value - ) + ), + CommandStack ); Model.ChartPackData.CurrentValue.MusicVersions.Add(deepClonedData); diff --git a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/NoteAttributeViewModel.cs b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/NoteAttributeViewModel.cs index ddba5f7f9..78f174d26 100644 --- a/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/NoteAttributeViewModel.cs +++ b/Cyan-Stars/Assets/Scripts/Gameplay/ChartEditor/ViewModel/NoteAttributeViewModel.cs @@ -5,6 +5,7 @@ using CyanStars.Gameplay.ChartEditor.Command; using CyanStars.Gameplay.ChartEditor.Model; using R3; +using UnityEngine; namespace CyanStars.Gameplay.ChartEditor.ViewModel { @@ -93,6 +94,9 @@ public void UpdateNoteJudgeBeat(string integerPart, string numerator, string den return; } + if (Model.SelectedNoteData.CurrentValue.JudgeBeat == newJudgeBeat) + return; + Beat oldJudgeBeat = Model.SelectedNoteData.CurrentValue.JudgeBeat; var note = Model.SelectedNoteData.CurrentValue; CommandStack.ExecuteCommand( @@ -139,6 +143,9 @@ public void UpdateNoteEndJudgeBeat(string integerPart, string numerator, string return; } + if (note.EndJudgeBeat == newEndBeat) + return; + Beat oldEndBeat = note.EndJudgeBeat; CommandStack.ExecuteCommand( () => @@ -168,6 +175,9 @@ public void UpdateNotePos(string pos) return; } + if (Mathf.Approximately(((IChartNoteNormalPos)Model.SelectedNoteData.CurrentValue).Pos, newPosFloat)) + return; + float oldPosFloat = ((IChartNoteNormalPos)Model.SelectedNoteData.CurrentValue).Pos; var note = (IChartNoteNormalPos)Model.SelectedNoteData.CurrentValue; CommandStack.ExecuteCommand( @@ -192,6 +202,9 @@ public void UpdateBreakNotePos(BreakNotePos newBreakPos) if (Model.SelectedNoteData.CurrentValue.Type != NoteType.Break) throw new Exception("SelectedNoteData is not break"); + if (((BreakChartNoteData)Model.SelectedNoteData.CurrentValue).BreakNotePos == newBreakPos) + return; + BreakNotePos oldBreakPos = ((BreakChartNoteData)Model.SelectedNoteData.CurrentValue).BreakNotePos; var note = (BreakChartNoteData)Model.SelectedNoteData.CurrentValue; CommandStack.ExecuteCommand(