UndoManager 并不是普通游戏流程中的常用系统,它主要用于游戏内 UGC 编辑器。
例如家园摆放、地图制作、关卡编辑、角色捏脸等功能,玩家会频繁修改作品,并希望随时撤销或恢复上一次操作。MyFramework 通过 UndoManager 保存这些编辑记录,同时利用同一套操作生成对应的重做记录。
项目地址:
一、适合哪些 UGC 功能
典型使用场景包括:
bash
家园和家具摆放
地图与关卡编辑器
角色捏脸和换装
建筑建造模式
技能节点编辑器
游戏内剧情编辑器
这些功能都有共同特点:
bash
玩家会连续进行大量编辑
每次编辑都会改变作品数据
操作结果需要立即预览
玩家可能随时撤销或重做
普通战斗、任务和角色移动通常不需要记录撤销历史。
另外,本文讨论的是游戏运行时的 UGC 编辑器 。如果只是开发者在 Unity Editor 中编辑场景,应优先使用 Unity 提供的 UnityEditor.Undo。
二、UndoManager 的内部结构
框架中的 MyUndo 非常简单:
bash
// 撤销操作基类
public abstract class MyUndo : ClassObject
{
public abstract void undo();
}
每一种可撤销操作都继承 MyUndo,并实现如何恢复之前的状态。
UndoManager 继承自 FrameSystem,内部主要保存两个列表:
bash
protected List<MyUndo> mUndoList = new(); // 撤销操作列表
protected List<MyUndo> mRedoList = new(); // 重做操作列表
protected int mMaxUndo = 10;
protected bool mUndoing;
protected bool mRedoing;
两个列表分别表示:
bash
mUndoList
当前可以撤销的操作
mRedoList
当前可以重新执行的操作
它们都是后进先出的结构。最后一次编辑,会最先被撤销。
三、为什么 MyUndo 只需要一个 undo()
通常实现撤销重做,会分别定义:
bash
undo
redo
但 MyFramework 只要求实现一个:
bash
public abstract void undo();
原因是执行撤销时,会自动记录当前状态作为反向操作。
假设玩家把家具从 A 移动到 B:
bash
正常编辑:
A → B
记录的撤销操作:
恢复到 A
执行撤销时,家具当前位于 B。恢复到 A 之前,框架又会记录:
bash
恢复到 B
这条记录就是重做操作。
因此整个过程是:
bash
A → B
记录"回到A"
撤销:
B → A
自动记录"回到B"
重做:
A → B
自动记录"回到A"
撤销和重做本质上都是"恢复到某个历史状态",所以可以共用同一个 undo()。
四、addUndo() 如何区分撤销和重做记录
UndoManager.addUndo() 的核心实现如下:
bash
public void addUndo(MyUndo undo)
{
bool lastCanUndo = canUndo();
bool lastCanRedo = canRedo();
// 正在执行撤销时,新产生的是重做操作
if (mUndoing)
{
mRedoList.Add(undo);
}
else
{
if (mUndoList.Count >= mMaxUndo)
{
UN_CLASS(mUndoList.removeAt(0));
}
mUndoList.Add(undo);
// 普通编辑会产生新的操作分支,需要清空重做列表
if (!mRedoing)
{
clearRedo();
}
}
if (canUndo() != lastCanUndo ||
canRedo() != lastCanRedo)
{
mUndoRedoChangeCallback?.Invoke();
}
}
它根据当前状态决定记录放到哪里。
正常编辑
bash
mUndoing = false
mRedoing = false
新的记录进入:
bash
mUndoList
同时清空原来的 mRedoList。
正在撤销
bash
mUndoing = true
恢复过程中产生的反向记录进入:
bash
mRedoList
正在重做
bash
mRedoing = true
重做过程中产生的反向记录重新进入:
bash
mUndoList
因此,业务代码不需要自己操作两个列表。
五、undo() 的内部实现
框架中的撤销逻辑是:
bash
public void undo()
{
bool lastCanUndo = canUndo();
mUndoing = true;
if (canUndo())
{
mUndoList[^1].undo();
UN_CLASS(mUndoList.popBack());
}
mUndoing = false;
if (canUndo() != lastCanUndo)
{
mUndoRedoChangeCallback?.Invoke();
}
}
执行过程为:
bash
将 mUndoing 设置为 true
↓
执行最后一个 MyUndo
↓
MyUndo 内部产生的反向记录进入 mRedoList
↓
移除并回收已经执行的撤销对象
↓
结束撤销状态
这里最关键的是:
bash
mUndoing = true;
只要 MyUndo.undo() 内部继续通过正常业务命令修改数据,命令创建的新记录就会被 addUndo() 自动放进重做列表。
六、redo() 的内部实现
重做的逻辑与撤销基本相同:
bash
public void redo()
{
bool lastCanRedo = canRedo();
mRedoing = true;
if (canRedo())
{
mRedoList[^1].undo();
UN_CLASS(mRedoList.popBack());
}
mRedoing = false;
if (canRedo() != lastCanRedo)
{
mUndoRedoChangeCallback?.Invoke();
}
}
执行重做时:
bash
从 mRedoList 取出最后一条记录
↓
调用同一个 undo()
↓
恢复过程中创建新的反向记录
↓
由于 mRedoing 为 true,反向记录进入 mUndoList
所以 MyUndo.undo() 中不能只直接修改数据,它通常需要调用正常的编辑命令,才能生成反向记录。
七、实现家具位置撤销
下面以 UGC 家具编辑器为例。
创建一个保存历史位置的撤销对象:
bash
using UnityEngine;
public class UndoSetPosition : MyUndo
{
protected ITransformable mTarget;
protected Vector3 mPosition;
public UndoSetPosition setData(
ITransformable target,
Vector3 position)
{
mTarget = target;
mPosition = position;
return this;
}
public override void undo()
{
if (mTarget == null || mTarget.isDestroy())
{
return;
}
CmdUGCSetPosition.execute(
mTarget,
mPosition);
}
public override void resetProperty()
{
base.resetProperty();
mTarget = null;
mPosition = Vector3.zero;
}
}
它保存:
bash
编辑对象
需要恢复的位置
MyUndo 继承自 ClassObject,所以全部字段都必须在 resetProperty() 中重置。
八、封装正常的位置修改命令
家具位置不能在各处直接修改,需要统一通过 Command:
bash
using UnityEngine;
using static FrameBaseHotFix;
using static FrameUtility;
public class CmdUGCSetPosition
{
public static void execute(
ITransformable target,
Vector3 position)
{
if (target == null || target.isDestroy())
{
return;
}
Vector3 oldPosition = target.getPosition();
if (oldPosition == position)
{
return;
}
mUndoManager.addUndo(
CLASS<UndoSetPosition>().setData(
target,
oldPosition));
target.setPosition(position);
}
}
正常编辑时:
bash
CmdUGCSetPosition.execute(
furniture,
newPosition);
命令先保存旧位置,再应用新位置。
执行撤销时,UndoSetPosition.undo() 也会调用同一个命令:
bash
CmdUGCSetPosition.execute(
mTarget,
mPosition);
此时命令读取当前位置,并生成对应的重做记录。
调用关系如下:
bash
普通编辑
CmdUGCSetPosition
├── 记录旧位置到 UndoList
└── 设置新位置
执行撤销
UndoSetPosition.undo
↓
CmdUGCSetPosition
├── 记录当前位置到 RedoList
└── 恢复旧位置
执行重做
UndoSetPosition.undo
↓
CmdUGCSetPosition
├── 记录当前位置到 UndoList
└── 恢复重做位置
这正是 UndoManager 内部两个状态标记的作用。
九、拖拽不能每帧添加记录
UGC 编辑器中,家具拖拽会持续很多帧。
不能在拖拽过程中反复调用:
bash
CmdUGCSetPosition.execute(
mDragTarget,
position);
否则一次拖拽会生成大量撤销记录。
正确方式是:
bash
开始拖拽
保存初始位置
拖拽过程中
直接更新预览位置
结束拖拽
只添加一条撤销记录
示例:
bash
protected ITransformable mDragTarget;
protected Vector3 mDragStartPosition;
protected void onDragStart(
ITransformable target)
{
mDragTarget = target;
mDragStartPosition = target.getPosition();
}
protected void onDragging(Vector3 position)
{
mDragTarget?.setPosition(position);
}
protected void onDragEnd()
{
if (mDragTarget == null ||
mDragTarget.isDestroy() ||
mDragTarget.getPosition() == mDragStartPosition)
{
mDragTarget = null;
return;
}
mUndoManager.addUndo(
CLASS<UndoSetPosition>().setData(
mDragTarget,
mDragStartPosition));
mDragTarget = null;
}
现在一次完整拖拽只会生成一条历史记录。
撤销时,命令会读取家具拖拽结束后的位置,从而自动生成重做记录。
十、为什么新操作会清空重做列表
假设编辑历史是:
bash
A → B → C
撤销后回到 B:
bash
可撤销:B → A
可重做:B → C
此时玩家没有重做,而是直接将内容编辑为 D:
bash
A → B → D
原来的 C 已经不属于当前编辑分支,所以必须清空重做列表。
对应的内部判断是:
bash
if (!mRedoing)
{
clearRedo();
}
普通编辑时 mRedoing 为 false,所以会清空重做记录。
重做过程中产生撤销记录时,mRedoing 为 true,不会误删剩余的重做列表。
十一、记录上限与对象池回收
UndoManager 默认最多保存十条撤销记录:
bash
protected int mMaxUndo = 10;
UGC 编辑器可以调整:
bash
mUndoManager.setMaxUndoCount(50);
超过最大数量时,会删除最早的记录:
bash
if (mUndoList.Count >= mMaxUndo)
{
UN_CLASS(mUndoList.removeAt(0));
}
撤销对象来自 ClassPool,记录被删除、执行或清空后,都会通过 UN_CLASS 回收。
清空所有记录:
bash
mUndoManager.clearAll();
内部会回收两个列表中的全部对象:
bash
UN_CLASS_LIST(mUndoList);
clearRedo();
进入新的 UGC 作品、切换地图或退出编辑器时,都应该调用 clearAll(),避免旧记录继续引用上一份作品中的对象。
十二、更新撤销和重做按钮
UndoManager 支持监听可用状态变化:
bash
mUndoManager.addUndoRedoChangeCallback(
refreshUndoRedo);
刷新按钮:
bash
protected void refreshUndoRedo()
{
mUndoButton.setActive(
mUndoManager.canUndo());
mRedoButton.setActive(
mUndoManager.canRedo());
}
界面关闭时注销:
bash
mUndoManager.removeUndoRedoChangeCallback(
refreshUndoRedo);
按钮点击:
bash
protected void onUndoClick()
{
mUndoManager.undo();
}
protected void onRedoClick()
{
mUndoManager.redo();
}
这样编辑器界面不需要自己判断列表数量。
十三、总结
UndoManager 主要面向游戏内 UGC 编辑器,而不是普通游戏业务。
它的内部实现依赖四个关键点:
bash
mUndoList
保存可以撤销的操作
mRedoList
保存可以重做的操作
mUndoing
让撤销过程中产生的反向操作进入 RedoList
mRedoing
让重做过程中产生的反向操作进入 UndoList
业务层接入时需要:
markdown
1. 将编辑行为封装成 Command
2. 创建对应的 MyUndo 保存历史状态
3. 正常编辑前调用 addUndo
4. MyUndo.undo() 继续调用正常编辑命令
5. 由 UndoManager 自动生成反向记录
6. 一次连续拖拽只记录一次
7. 离开 UGC 编辑器时调用 clearAll
这样位置、旋转、缩放、创建、删除和属性修改等不同 UGC 操作,都可以共用同一套撤销与重做机制。