尝试一些更改并在 Java 中失败时回滚到以前的状态



在我正在写的游戏中,有一个玩家在棋盘上移动。 在这个板上还有其他物体。 所以我有一堆对象,每个对象都保持着自己的状态。 可以要求玩家移动到特定的相邻单元格。 它的移动由三个离散的部分组成:退出其当前单元;从当前单元格遍历到目标单元格;进入新单元格。 这些阶段中的每一个都可能触发电路板上对象的一些更改。 玩家要么执行完整的移动,要么根本不移动。 可能存在障碍物,阻止玩家在三个阶段中的每一个阶段到达目标单元格。 问题是判断玩家是否可以执行移动,并且当且仅当它可以移动时移动它。

我首先想到的是,我可以检查是否有障碍物阻止玩家离开其当前单元格,是否没有障碍物阻止它向目标单元格移动,以及是否有障碍物阻止它进入目标单元格。 如果所有这些条件都匹配,那么我移动玩家; 如果没有,那么我不移动玩家,棋盘的状态也没有改变。

但事情并没有那么简单。 事实上,即使三个测试成功,玩家也可能无法移动。 例如,当它离开其单元时,可能会触发电路板的更改,使其以后无法过渡到目标单元。 在这种情况下,一切都必须保持不变:玩家不应该移动(因为它不能执行完整的移动),棋盘应该保持不变,这意味着不应该触发任何事件。

由于我事先无法知道玩家是否可以移动,而只是尝试移动它,因此我想到了另一种方法。 我可以尝试在不事先检查任何内容的情况下移动播放器,并在三个移动步骤中的任何一个失败的情况下回滚到以前的状态。 回滚是必要的,因为在尝试移动时,可能已触发事件,因此板可能已相应地更改。

问题如下。 如何尝试执行一些代码,这些代码可能会更改某些对象的状态,然后在发生某些事情时回滚到以前的状态? 更具体地说,假设我有一个返回布尔值的方法move,并更改某些对象的状态。 如果该方法返回 true,我想保留更改,否则回滚所有更改。 如何实施这种行为?

或者你有更好的主意来解决所描述的问题吗?

这是一个很好的方法。数据库一直在这样做。

不幸的是,这个问题有很多很多答案,而这些答案中最合适的选择取决于您的具体情况,即数据存储机制。

使用数据库

最简单的可用是数据库。

这涉及以下设置:

  1. 确保您始终使用事务处理所有事务。最好甚至是只读操作。
  2. 确保你有一个非愚蠢的数据库。例如,MySQL MyISAM算作一个愚蠢的数据库;不要使用它。大多数基于 SQL 的数据库在这方面都是理智的。对于此特定用例,避免使用mongodb和其他此类基于文档的数据库引擎。您正在寻找一个支持 SERIALIZABLE 级别事务并实际遵守其规则的数据库(与许多数据库引擎相比,它们像支持它一样,但实际上并没有为您提供 SERIALIZABLE 描述的保证)。
  3. 设置连接以使用 TransactionLevel.SERIALIZABLE。
  4. 这本质上意味着所谓的"重试"。要管理这一点,您需要将与数据库交互的任何和所有代码包装在 lambda 中,以便底层框架可以在必要时重新运行您的代码。一般来说,不要直接使用 JDBC,而是使用基于它构建的更好的抽象。您有两种主流选择:JOOQ和JDBI。这两个优秀库的教程都包含有关如何正确操作的详细信息。

一旦你有了所有这些,"失败操作,什么都不改变"就像退出你的"做这些数据操作"(这是一个代码块,充满了DB查询语句(sqlINSERT/UPDATE/DELETE/SELECT),交给jdbi/jooq)一样简单。事务将被回滚,您在数据库中更改的任何内容实际上都不会"坚持"。同样有用的是:所有其他代码都会在一个瞬间看到你开始"移动玩家"歌舞例程之前的情况,然后在下一个瞬间看到,就好像一切都应用了一样。换句话说,玩家的移动最终表现得好像它是原子的,这可能是这个模型正常工作的要求。如果您将其设置为多线程设置,则任何其他代码都不应该半步执行!

如果您当前没有使用数据库,那么它会变得更加复杂。您或多或少地注册编写自己的MVCC风格的数据库引擎,这不是一件容易的事。

有一些更简单的答案。像往常一样,为了获得简单的答案,它们对于您的特定用例可能完全不可行。

不可变/克隆

如果描述游戏状态的数据结构是 100% 不可变的(零二传手),或者即使不是:只要你的"移动播放器"代码实际上根本没有修改任何东西,而是在过程的每一步都对/产生新的略微修改的克隆进行操作/生成新的稍微修改的克隆,那么"中止"是微不足道的:只是......返回。

游戏状态实际更新的唯一方式是更新指向表示整个游戏状态的对象的一个字段,这是在最后更新的。在 java 中更改引用(任何非原始变量都是引用)是原子的。

如果您的游戏状态非常大,这可能是一个坏主意,因为它必然涉及完整的副本。

回滚日志

另一种选择是,您可以对游戏状态执行的每个操作都与撤消该操作的镜像实现配对。您对状态所做的每个"更改"都以 Operation 对象的形式进行描述,并且每个 Operation 内部都有代码,既知道如何将更改应用于基础游戏状态,又知道如何取消应用它。可以通过向队列添加新操作来实现应用。

例如:

public class HitCreature extends Operation {
private Player actor;
private Creature target;
private Weapon weapon;
public void apply() {
target.health -= actor.getStrength();
if (weapon.hasKnockBack()) {
//calculate direction...
queue.addOperation(new KnockbackCreature(target, direction));
}
if (target.isDead()) {
queue.addOperation(new RemoveCreature(target));
queue.addOperation(new AddSkeleton(target));
}
}
public void unapply() {
target.health += actor.getStrength();
}
}

任何给定的移动都只是一个分解为多个操作的单个操作,并且您按顺序维护一个列表,每个操作都在其上。要么你到达这个列表的末尾而没有任何错误,在这种情况下,太好了,一切都成功了。或者,其中一个操作失败,在这种情况下,请回到其中的 0 索引,调用unapply,这应该完全撤消它所做的一切(并且取消应用代码不能添加新操作,它只是撤消它直接更改的内容而不会引起任何进一步的副作用)。

但是,如果您使用的是多线程代码,则这不是bueno。

其他选项

还有更多的策略,但希望这些策略能给你一些关于从这里开始的想法。

最新更新