Unity MyFramework 用法说明(二十一):使用 UndoManager 为游戏内 UGC 编辑器实现撤销与重做

0 阅读7分钟

UndoManager 并不是普通游戏流程中的常用系统,它主要用于游戏内 UGC 编辑器

例如家园摆放、地图制作、关卡编辑、角色捏脸等功能,玩家会频繁修改作品,并希望随时撤销或恢复上一次操作。MyFramework 通过 UndoManager 保存这些编辑记录,同时利用同一套操作生成对应的重做记录。

项目地址:

github.com/ZHOURUIH/My…

一、适合哪些 UGC 功能

典型使用场景包括:

家园和家具摆放
地图与关卡编辑器
角色捏脸和换装
建筑建造模式
技能节点编辑器
游戏内剧情编辑器

这些功能都有共同特点:

玩家会连续进行大量编辑
每次编辑都会改变作品数据
操作结果需要立即预览
玩家可能随时撤销或重做

普通战斗、任务和角色移动通常不需要记录撤销历史。

另外,本文讨论的是游戏运行时的 UGC 编辑器。如果只是开发者在 Unity Editor 中编辑场景,应优先使用 Unity 提供的 UnityEditor.Undo

二、UndoManager 的内部结构

框架中的 MyUndo 非常简单:

// 撤销操作基类
public abstract class MyUndo : ClassObject
{
	public abstract void undo();
}

每一种可撤销操作都继承 MyUndo,并实现如何恢复之前的状态。

UndoManager 继承自 FrameSystem,内部主要保存两个列表:

protected List<MyUndo> mUndoList = new();	// 撤销操作列表
protected List<MyUndo> mRedoList = new();	// 重做操作列表

protected int mMaxUndo = 10;
protected bool mUndoing;
protected bool mRedoing;

两个列表分别表示:

mUndoList
当前可以撤销的操作

mRedoList
当前可以重新执行的操作

它们都是后进先出的结构。最后一次编辑,会最先被撤销。

三、为什么 MyUndo 只需要一个 undo()

通常实现撤销重做,会分别定义:

undo
redo

但 MyFramework 只要求实现一个:

public abstract void undo();

原因是执行撤销时,会自动记录当前状态作为反向操作。

假设玩家把家具从 A 移动到 B:

正常编辑:
A → B

记录的撤销操作:
恢复到 A

执行撤销时,家具当前位于 B。恢复到 A 之前,框架又会记录:

恢复到 B

这条记录就是重做操作。

因此整个过程是:

A → B
记录“回到A”

撤销:
B → A
自动记录“回到B”

重做:
A → B
自动记录“回到A”

撤销和重做本质上都是“恢复到某个历史状态”,所以可以共用同一个 undo()

四、addUndo() 如何区分撤销和重做记录

UndoManager.addUndo() 的核心实现如下:

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();
	}
}

它根据当前状态决定记录放到哪里。

正常编辑

mUndoing = false
mRedoing = false

新的记录进入:

mUndoList

同时清空原来的 mRedoList

正在撤销

mUndoing = true

恢复过程中产生的反向记录进入:

mRedoList

正在重做

mRedoing = true

重做过程中产生的反向记录重新进入:

mUndoList

因此,业务代码不需要自己操作两个列表。

五、undo() 的内部实现

框架中的撤销逻辑是:

public void undo()
{
	bool lastCanUndo = canUndo();
	mUndoing = true;

	if (canUndo())
	{
		mUndoList[^1].undo();
		UN_CLASS(mUndoList.popBack());
	}

	mUndoing = false;

	if (canUndo() != lastCanUndo)
	{
		mUndoRedoChangeCallback?.Invoke();
	}
}

执行过程为:

将 mUndoing 设置为 true
        ↓
执行最后一个 MyUndo
        ↓
MyUndo 内部产生的反向记录进入 mRedoList
        ↓
移除并回收已经执行的撤销对象
        ↓
结束撤销状态

这里最关键的是:

mUndoing = true;

只要 MyUndo.undo() 内部继续通过正常业务命令修改数据,命令创建的新记录就会被 addUndo() 自动放进重做列表。

六、redo() 的内部实现

重做的逻辑与撤销基本相同:

public void redo()
{
	bool lastCanRedo = canRedo();
	mRedoing = true;

	if (canRedo())
	{
		mRedoList[^1].undo();
		UN_CLASS(mRedoList.popBack());
	}

	mRedoing = false;

	if (canRedo() != lastCanRedo)
	{
		mUndoRedoChangeCallback?.Invoke();
	}
}

执行重做时:

从 mRedoList 取出最后一条记录
        ↓
调用同一个 undo()
        ↓
恢复过程中创建新的反向记录
        ↓
由于 mRedoing 为 true,反向记录进入 mUndoList

所以 MyUndo.undo() 中不能只直接修改数据,它通常需要调用正常的编辑命令,才能生成反向记录。

七、实现家具位置撤销

下面以 UGC 家具编辑器为例。

创建一个保存历史位置的撤销对象:

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;
	}
}

它保存:

编辑对象
需要恢复的位置

MyUndo 继承自 ClassObject,所以全部字段都必须在 resetProperty() 中重置。

八、封装正常的位置修改命令

家具位置不能在各处直接修改,需要统一通过 Command:

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);
	}
}

正常编辑时:

CmdUGCSetPosition.execute(
	furniture,
	newPosition);

命令先保存旧位置,再应用新位置。

执行撤销时,UndoSetPosition.undo() 也会调用同一个命令:

CmdUGCSetPosition.execute(
	mTarget,
	mPosition);

此时命令读取当前位置,并生成对应的重做记录。

调用关系如下:

普通编辑
CmdUGCSetPosition
    ├── 记录旧位置到 UndoList
    └── 设置新位置

执行撤销
UndoSetPosition.undo
    ↓
CmdUGCSetPosition
    ├── 记录当前位置到 RedoList
    └── 恢复旧位置

执行重做
UndoSetPosition.undo
    ↓
CmdUGCSetPosition
    ├── 记录当前位置到 UndoList
    └── 恢复重做位置

这正是 UndoManager 内部两个状态标记的作用。

九、拖拽不能每帧添加记录

UGC 编辑器中,家具拖拽会持续很多帧。

不能在拖拽过程中反复调用:

CmdUGCSetPosition.execute(
	mDragTarget,
	position);

否则一次拖拽会生成大量撤销记录。

正确方式是:

开始拖拽
保存初始位置

拖拽过程中
直接更新预览位置

结束拖拽
只添加一条撤销记录

示例:

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;
}

现在一次完整拖拽只会生成一条历史记录。

撤销时,命令会读取家具拖拽结束后的位置,从而自动生成重做记录。

十、为什么新操作会清空重做列表

假设编辑历史是:

A → B → C

撤销后回到 B:

可撤销:B → A
可重做:B → C

此时玩家没有重做,而是直接将内容编辑为 D:

A → B → D

原来的 C 已经不属于当前编辑分支,所以必须清空重做列表。

对应的内部判断是:

if (!mRedoing)
{
	clearRedo();
}

普通编辑时 mRedoingfalse,所以会清空重做记录。

重做过程中产生撤销记录时,mRedoingtrue,不会误删剩余的重做列表。

十一、记录上限与对象池回收

UndoManager 默认最多保存十条撤销记录:

protected int mMaxUndo = 10;

UGC 编辑器可以调整:

mUndoManager.setMaxUndoCount(50);

超过最大数量时,会删除最早的记录:

if (mUndoList.Count >= mMaxUndo)
{
	UN_CLASS(mUndoList.removeAt(0));
}

撤销对象来自 ClassPool,记录被删除、执行或清空后,都会通过 UN_CLASS 回收。

清空所有记录:

mUndoManager.clearAll();

内部会回收两个列表中的全部对象:

UN_CLASS_LIST(mUndoList);
clearRedo();

进入新的 UGC 作品、切换地图或退出编辑器时,都应该调用 clearAll(),避免旧记录继续引用上一份作品中的对象。

十二、更新撤销和重做按钮

UndoManager 支持监听可用状态变化:

mUndoManager.addUndoRedoChangeCallback(
	refreshUndoRedo);

刷新按钮:

protected void refreshUndoRedo()
{
	mUndoButton.setActive(
		mUndoManager.canUndo());

	mRedoButton.setActive(
		mUndoManager.canRedo());
}

界面关闭时注销:

mUndoManager.removeUndoRedoChangeCallback(
	refreshUndoRedo);

按钮点击:

protected void onUndoClick()
{
	mUndoManager.undo();
}

protected void onRedoClick()
{
	mUndoManager.redo();
}

这样编辑器界面不需要自己判断列表数量。

十三、总结

UndoManager 主要面向游戏内 UGC 编辑器,而不是普通游戏业务。

它的内部实现依赖四个关键点:

mUndoList
保存可以撤销的操作

mRedoList
保存可以重做的操作

mUndoing
让撤销过程中产生的反向操作进入 RedoList

mRedoing
让重做过程中产生的反向操作进入 UndoList

业务层接入时需要:

1. 将编辑行为封装成 Command
2. 创建对应的 MyUndo 保存历史状态
3. 正常编辑前调用 addUndo
4. MyUndo.undo() 继续调用正常编辑命令
5. 由 UndoManager 自动生成反向记录
6. 一次连续拖拽只记录一次
7. 离开 UGC 编辑器时调用 clearAll

这样位置、旋转、缩放、创建、删除和属性修改等不同 UGC 操作,都可以共用同一套撤销与重做机制。