使用命令模式让玩家可撤销游戏中错误决策

/**
 * 翻译力求准确,信达雅谈不上,如果有不准确的欢迎大家指出,以便修
 * @see http://gamedev.tutsplus.com/tutorials/implementation/let-your-players-undo-their-in-game-mistakes-with-the-command-pattern/
 */


许多回合制游戏包含一个撤销按钮让玩家在游戏过程中能返回之前的错误。此功能尤其适合手游开发,因为触屏有时候会产生失真的触摸识别。相比每次操作之前都问“确定执行这一步?”而言,如果在游戏中犯错后能够撤销到上一步操作之前的状态那可有效率的多。在本教程中,我们将学习如何使用命令模式来实现这种效果。我们以井字棋游戏为例。

注意:尽管该教程是用java写的,但你几乎能够在任何一种游戏开发环境中使用相同的技术和观念(当然也不只限于井字棋)

最终结果预览

本教程的最终结果是一个提供无限次撤销和重做功能的井字棋游戏,代码请到原网站下载

不能加载java applet?那就看youtube上面的视频吧(天朝老百姓就甭想了)

你可以将TicTacToeMain作为主类在命令行中执行该demo。把源码解压缩以后执行以下命令:

javac *.java
java TicTacToeMain

第一步:井字棋的基本实现

本教程中,你要考虑井字棋游戏的基本实现,尽管游戏本身微不足道,但是教程中讲述的概念能够用于更复杂的游戏。

下面的下载文件(与最后完成文件不同)中包含了一个井字棋游戏的基本实现模型,该模型不包含撤销重做功能。你的工作就是跟随本教程为它添加这些功能。下载基础版的井字棋游戏代码

你要特别注意以下方法

public void placeX(int row, int col) {
    assert(playerXTurn);
    assert(spaces[row][col] == 0);
    spaces[row][col] = 1;
    playerXTurn = false;
}

public void placeO(int row, int col) {
    assert(!playerXTurn);
    assert(spaces[row][col] == 0);
    spaces[row][col] = 2;
    playerXTurn = true;
}

这些方法就是游戏中改变格子状态的唯一方法。它们就是你将要改变的。

如果你不是一个java开发者,你可能仍然能够理解代码。我将其拷贝到这里,以便你需要参考的话:

/** The game logic for a Tic-Tac-Toe game. This model does not have
 * an associated User Interface: it is just the game logic.
 * 
 * The game is represented by a simple 3x3 integer array. A value of
 * 0 means the space is empty, 1 means it is an X, 2 means it is an O. 
 * 
 * @author aarnott
 *
 */
public class TicTacToeModel {
    //True if it is the X player’s turn, false if it is the O player’s turn
    private boolean playerXTurn;
    //The set of spaces on the game grid
    private int[][] spaces;
     
    /** Initialize a new game model. In the traditional Tic-Tac-Toe
     * game, X goes first.
     * 
     */
    public TicTacToeModel() {
        spaces = new int[3][3]; 
        playerXTurn = true;
    }
     
    /** Returns true if it is the X player's turn.
     * 
     * @return
     */
    public boolean isPlayerXTurn() {
        return playerXTurn;
    }
     
    /** Returns true if it is the O player's turn.
     * 
     * @return
     */
    public boolean isPlayerOTurn() {
        return !playerXTurn;
    }
     
    /** Places an X on a space specified by the row and column
     * parameters.
     * 
     * Preconditions:
     * -> It must be the X player's turn
     * -> The space must be empty
     * 
     * @param row The row to place the X on
     * @param col The column to place the X on
     */
    public void placeX(int row, int col) {
        assert(playerXTurn);
        assert(spaces[row][col] == 0);
        spaces[row][col] = 1;
        playerXTurn = false;
    }
     
    /** Places an O on a space specified by the row and column
     * parameters.
     * 
     * Preconditions:
     * -> It must be the O player's turn
     * -> The space must be empty
     * 
     * @param row The row to place the O on
     * @param col The column to place the O on
     */
    public void placeO(int row, int col) {
        assert(!playerXTurn);
        assert(spaces[row][col] == 0);
        spaces[row][col] = 2;
        playerXTurn = true;
    }
     
    /** Returns true if a space on the grid is empty (no Xs or Os)
     *  
     * @param row
     * @param col
     * @return
     */
    public boolean isSpaceEmpty(int row, int col) {
        return (spaces[row][col] == 0);
    }
     
    /** Returns true if a space on the grid is an X.
     *  
     * @param row
     * @param col
     * @return
     */
    public boolean isSpaceX(int row, int col) {
        return (spaces[row][col] == 1);
    }
     
    /** Returns true if a space on the grid is an O.
     *  
     * @param row
     * @param col
     * @return
     */
    public boolean isSpaceO(int row, int col) {
        return (spaces[row][col] == 2);
    }
     
    /** Returns true if the X player won the game. That is, if the
     * X player has completed a line of three Xs.
     * 
     * @return
     */
    public boolean hasPlayerXWon() {
        //Check rows
        if(spaces[0][0] == 1 && spaces[0][1] == 1 && spaces[0][2] == 1) return true;
        if(spaces[1][0] == 1 && spaces[1][1] == 1 && spaces[1][2] == 1) return true;
        if(spaces[2][0] == 1 && spaces[2][1] == 1 && spaces[2][2] == 1) return true;
        //Check columns
        if(spaces[0][0] == 1 && spaces[1][0] == 1 && spaces[2][0] == 1) return true;
        if(spaces[0][1] == 1 && spaces[1][1] == 1 && spaces[2][1] == 1) return true;
        if(spaces[0][2] == 1 && spaces[1][2] == 1 && spaces[2][2] == 1) return true;
        //Check diagonals
        if(spaces[0][0] == 1 && spaces[1][1] == 1 && spaces[2][2] == 1) return true;
        if(spaces[0][2] == 1 && spaces[1][1] == 1 && spaces[2][0] == 1) return true;
        //Otherwise, there is no line
        return false;
    }
     
    /** Returns true if the O player won the game. That is, if the
     * O player has completed a line of three Os.
     * 
     * @return
     */
    public boolean hasPlayerOWon() {
        //Check rows
        if(spaces[0][0] == 2 && spaces[0][1] == 2 && spaces[0][2] == 2) return true;
        if(spaces[1][0] == 2 && spaces[1][1] == 2 && spaces[1][2] == 2) return true;
        if(spaces[2][0] == 2 && spaces[2][1] == 2 && spaces[2][2] == 2) return true;
        //Check columns
        if(spaces[0][0] == 2 && spaces[1][0] == 2 && spaces[2][0] == 2) return true;
        if(spaces[0][1] == 2 && spaces[1][1] == 2 && spaces[2][1] == 2) return true;
        if(spaces[0][2] == 2 && spaces[1][2] == 2 && spaces[2][2] == 2) return true;
        //Check diagonals
        if(spaces[0][0] == 2 && spaces[1][1] == 2 && spaces[2][2] == 2) return true;
        if(spaces[0][2] == 2 && spaces[1][1] == 2 && spaces[2][0] == 2) return true;
        //Otherwise, there is no line
        return false;
    }
     
    /** Returns true if all the spaces are filled or one of the players has
     * won the game.
     * 
     * @return
     */
    public boolean isGameOver() {
        if(hasPlayerXWon() || hasPlayerOWon()) return true;
        //Check if all the spaces are filled. If one isn’t the game isn’t over
        for(int row = 0; row < 3; row++) {
            for(int col = 0; col < 3; col++) {
                if(spaces[row][col] == 0) return false;
            }
        }
        //Otherwise, it is a “cat’s game”
        return true;
    }   
 
}

第二步:理解命令模式


命令模式是设计模式的一种,它通常被用于分离用户界面上定义可视组件的代码(按钮,菜单等等可视组件)与操作这些组件后的执行代码。将操作代码分离开这个概念可以用于跟踪游戏状态的变化,你可以使用这些信息来执行撤销变化。

命令模式的最基本版本是下面的接口:

public interface Command {
    public void execute();
}

任何一种由程序引发改变游戏状态的动作(像在特定区域放置一个X)都要实现Command接口。当动作发生时,execute()方法就会被调用。

现在,你可能已经注意到该接口没有提供撤销操作,它所做的就是将游戏从一个状态转换为另外一种状态。下面的改进将会为实现接口的代码提供撤销能力。

public interface Command {
    public void execute();
    public void undo();
}

我们的目标是实现Command接口的代码里的undo()函数来撤销execute()函数的执行结果,相反地execute()方法要提供重做的能力。

这是基本想法,随着我们为该游戏实现特定的命令它会逐渐变得清晰

第三步:创建命令管理器


要添加撤销功能,你将要创建一个CommandManager类。CommandManager类负责跟踪,执行和撤销命令的实现。

回想一下命令接口提供了将程序从一个状态转换为另外一个状态的方法,也提供了从当前状态反转回上一个状态的方法.

public class CommandManager {
    private Command lastCommand;
 
    public CommandManager() {}
 
    public void executeCommand(Command c) {
        c.execute();
        lastCommand = c;
    }
 
    ...
 
}

要执行一个命令,给CommandManager类传入一个Command实例,CommandManager会执行Command并将最近执行的命令保存起来以便后续使用

为CommandManager添加撤销功能只需要告诉它撤销最近执行的命令

public boolean isUndoAvailable() {
    return lastCommand != null;
}
 
public void undo() {
    assert(lastCommand != null);
    lastCommand.undo();
    lastCommand = null;
}

这段代码全部所需就是一个课运行的CommandManager。为了使其运转正常,你需要创建Command接口的对应实现

第四步:创建命令接口的对应实现


本教程中命令模式的目标是将任何改变井字棋状态的代码转到Command实例中。换句话说,placeX()和placeO()方法里的代码就是你要改变的。

在TicTacToeModel类里面,添加了两个内部类分别是PlaceXCommand和PlaceOCommand,它们都实现了Command接口。

public class TicTacToeModel {
 
    ...
 
    private class PlaceXCommand implements Command {
 
        public void execute() {
            ...
        }
 
        public void undo() {
            ...
        }
 
    }
 
    private class PlaceOCommand implements Command {
 
        public void execute() {
            ...
        }
 
        public void undo() {
            ...
        }
 
    }
 
}

实现Command接口的主要任务是存储状态以及一些一些逻辑控制,这些逻辑控制主要涉及Command执行后由当前状态到新状态的转换以及由当前状态返回Command执行之前的初始状态的转换。有两种简单的方法来实现这样的功能。
1.存储整个的前一状态和下一状态。当execute()方法执行后将游戏当前状态转换为下一状态,当undo()方法调用后将当前状态转换为之前存储的状态。
2.只保存状态间变化的信息,当execute()或undo()调用时只改变这些存储的信息

//第一种:存储当前和下一状态
//Option 1: Storing the previous and next states
private class PlaceXCommand implements Command {
    private TicTacToeModel model;
    //
    private int[][] previousGridState;
    private boolean previousTurnState;
    private int[][] nextGridState;
    private boolean nextTurnState;    
    //
    private PlaceXCommand (TicTacToeModel model, int row, int col) {
        this.model = model;
        //
        previousTurnState = model.playerXTurn;
        //Copy the entire grid for both states
        previousGridState = new int[3][3];
        nextGridState = new int[3][3];
        for(int i = 0; i < 3; i++) {
            for(int j = 0; j < 3; j++) {
                //This is allowed because this class is an inner
                //class. Otherwise, the model would need to 
                //provide array access somehow.
                previousGridState[i][j] = m.spaces[i][j];
                nextGridState[i][j] = m.spaces[i][j];                
            }
        }
        //Figure out the next state by applying the placeX logic
        nextGridState[row][col] = 1;
        nextTurnState = false;
    }
    //
    public void execute() {
        model.spaces = nextGridState;
        model.playerXTurn = nextTurnState;
    }
    //
    public void undo() {
        model.spaces = previousGridState;
        model.playerXTurn = previousTurnState;
    }
}

第一个方案有点浪费,但这并不意味着这种设计不好。代码很简单明了,除非状态信息非常大时我们才会担心其浪费掉太多的计算机资源(内存、CPU等)。
在本教程中,你会发现第二种方案好点,但这种方法并不是对每一个程序都是最佳方案。然而大多数情况下我们会选择这种方案

//Option 2: Storing only the changes between states
private class PlaceXCommand implements Command {
    private TicTacToeModel model;
    private int previousValue;
    private boolean previousTurn;
    private int row;
    private int col;
    //
    private PlaceXCommand(TicTacToeModel model, int row, int col) {
        this.model = model;
        this.row = row;
        this.col = col;
        //Copy the previous value from the grid
        this.previousValue = model.spaces[row][col];
        this.previousTurn = model.playerXTurn;
    }
    //
    public void execute() {
        model.spaces[row][col] = 1;     
        model.playerXTurn = false;
    }
    //  
    public void undo() {
        model.spaces[row][col] = previousValue;
        model.playerXTurn = previousTurn;
    }       
}

第二种方案只保存发生的变化,而不是整个的状态信息。在井字棋使用这种方法效率更高而且复杂度也不高

内部类PlaceOCommand使用相似的方法写的,自己动手时间写出来吧。


第五步:将所有融合
为了使用Command的对应实现,PlaceXCommand和PlaceOCommand,你需要修改TicTacModel类。该类必须使用CommandManager而且必须使用Command实例而不是直接应用操作。

public class TicTacToeModel {
    private CommandManager commandManager;
    //
    ...
    //
    public TicTacToeModel() {
        ...
        //
        commandManager = new CommandManager();
    }
    //
    ...
    //
    public void placeX(int row, int col) {
        assert(playerXTurn);
        assert(spaces[row][col] == 0);
        commandManager.executeCommand(new PlaceXCommand(this, row, col));
    }
    //
    public void placeO(int row, int col) {
        assert(!playerXTurn);
        assert(spaces[row][col] == 0);
        commandManager.executeCommand(new PlaceOCommand(this, row, col));
    }
    //
    ...
}

TicTacToeModel和改变之前工作完全一致,但你也能够添加撤销操作,为类添加undo()方法同时添加canUndo方法以防用户界面要调用这个方法.

public class TicTacToeModel {
    //
    ...
    //
    public boolean canUndo() {
        return commandManager.isUndoAvailable();
    }
    //
    public void undo() {
        commandManager.undo();
    }
 
}

现在你有了一个功能完整的井字棋模型,而且它支持撤销操作。

第六步:你可以走的更远


对CommandManager稍作修改你就可以添加支持重做的功能以及不限次数的撤销与重做。
重做功能其背后的原理与撤销几乎一样,除了保存最后执行的Command,你也可以保存最后一个被撤销的命令。当撤销执行时你保存那个命令,当Command执行时则清除原先保存的命令

public class CommandManager {
 
    private Command lastCommandUndone;
 
    ...
 
    public void executeCommand(Command c) {
        c.execute();
        lastCommand = c;
        lastCommandUndone = null;
    }
 
    public void undo() {
        assert(lastCommand != null);
        lastCommand.undo();
        lastCommandUndone = lastCommand;
        lastCommand = null;
    }
 
    public boolean isRedoAvailable() {
        return lastCommandUndone != null;
    }
 
    public void redo() {
        assert(lastCommandUndone != null);
        lastCommandUndone.execute();
        lastCommand = lastCommandUndone;
        lastCommandUndone = null;
    }
}

添加多次撤销与重做其实是将撤销操作与重做操作保存在栈里。当一个新的操作被执行后,则将它添加到撤销栈同时重做对战清空。当一个操作被撤消后,则将其添加到重做栈并将其从撤销栈中删除。当一个操作被重做后,则将其从重做栈移除并添加到撤销栈。



上图展示了操作栈的例子。重做栈有两个被撤销的命令。当新命令Place(0,0)和PlaceO(0,1)被执行后,重做栈被清空,这两个命令被添加到撤销栈。当命令Place(0,1)被重做后它被从撤销栈栈顶移除并添加到重做栈中。
下面是对应代码:

public class CommandManager {
 
    private Stack<Command> undos = new Stack<Command>();
    private Stack<Command> redos = new Stack<Command>();
 
    public void executeCommand(Command c) {
        c.execute();
        undos.push(c);
        redos.clear();
    }
 
    public boolean isUndoAvailable() {
        return !undos.empty();
    }
 
    public void undo() {
        assert(!undos.empty());
        Command command = undos.pop();
        command.undo();
        redos.push(command);
    }
 
    public boolean isRedoAvailable() {
        return !redos.empty();
    }
 
    public void redo() {
        assert(!redos.empty());
        Command command = redos.pop();
        command.execute();
        undos.push(command);
    }
}

现在你有了个井字棋游戏模型,它能够撤销所有的操作指导回到游戏最初状态或者重做他们到达撤销之前的状态。

如果你想看这一切是怎么结合在一块的下载最终的代码其中包含该教程的完整代码

结语

你或许已注意到你写的最终的CommandManager可以应用于任何Command实现。这意味着你可以用你最喜欢的编程语言来编写CommandManager,创建Command接口的一些实例,而且准备了一个完整的撤销/重做系统。撤销操作是一个很棒的功能,它能够允许用户体验你的游戏且在犯错是不必觉得决策失误。

感谢你对本教程能有兴趣。
作为一个课后思考题:思考如下:命令模式以及CommandManager允许你跟踪游戏时的每一个状态变化。如果你保存了这些信息,你可以创建程序执行的回放。


 

你可能感兴趣的:(使用命令模式让玩家可撤销游戏中错误决策)