1. Code
  2. Game Development

Deje a sus jugadores deshacer sus errores en el juego con el patrón de comandos

Muchos juegos basados en turnos incluyen un botón de deshacer para permitir a los jugadores revertir los errores cometidos durante el juego. Esta característica se vuelve especialmente relevante para el desarrollo de juegos móviles donde el toque puede tener un reconocimiento táctil torpe. En lugar de confiar en un sistema en el que se pregunta al usuario "¿está seguro de que desea realizar esta tarea?" para cada acción que toman, es mucho más eficiente dejarlos cometer errores y tener la opción de invertir fácilmente su acción. En este tutorial, veremos cómo implementar esto usando el patrón de comandos, usando el ejemplo de un juego tic-tac-toe.
Scroll to top

Spanish (Español) translation by Elías Nicolás (you can also view the original English article)

Muchos juegos basados en turnos incluyen un botón de deshacer para permitir a los jugadores revertir los errores cometidos durante el juego. Esta característica se vuelve especialmente relevante para el desarrollo de juegos móviles donde el toque puede tener un reconocimiento táctil torpe. En lugar de confiar en un sistema en el que se pregunta al usuario "¿está seguro de que desea realizar esta tarea?" para cada acción que toman, es mucho más eficiente dejarlos cometer errores y tener la opción de invertir fácilmente su acción. En este tutorial, veremos cómo implementar esto usando el patrón de comandos, usando el ejemplo de un juego tic-tac-toe.

Nota: Aunque este tutorial está escrito con Java, debería ser capaz de usar las mismas técnicas y conceptos en casi cualquier entorno de desarrollo de juegos. (No se limita a los juegos de tic-tac-toe, tampoco!)


Vista previa del resultado final

El resultado final de este tutorial es un juego tic-tac-toe que ofrece ilimitadas deshacer y rehacer operaciones.

Esta demostración requiere que Java funcione.

¿No se puede cargar el applet? Mira el video del juego en YouTube:

También puede ejecutar la demostración en la línea de comandos utilizando TicTacToeMain como la clase principal de la que ejecutar. Después de extraer el código fuente, ejecute los siguientes comandos:

1
2
javac *.java
3
java TicTacToeMain

Paso 1: Crear una implementación básica de Tic-Tac-Toe

Para este tutorial, vas a considerar una implementación de tic-tac-toe. Aunque el juego es extremadamente trivial, los conceptos proporcionados en este tutorial pueden aplicarse a juegos mucho más complejos.

La siguiente descarga (que es diferente de la descarga de la fuente final) contiene el código básico para un modelo de juego tic-tac-toe que no contiene una función de deshacer o rehacer. Será su trabajo seguir este tutorial y agregar estas características. Descargue la base TicTacToeModel.java.

Debe tomar nota, en particular, de los siguientes métodos:

1
public void placeX(int row, int col) {
2
    assert(playerXTurn);
3
    assert(spaces[row][col] == 0);
4
    spaces[row][col] = 1;
5
    playerXTurn = false;
6
}
1
public void placeO(int row, int col) {
2
  assert(!playerXTurn);
3
	assert(spaces[row][col] == 0);
4
	spaces[row][col] = 2;
5
	playerXTurn = true;
6
}

Estos métodos son los únicos métodos para este juego que cambian el estado de la rejilla del juego. Ellos serán lo que va a cambiar.

Si no eres un desarrollador de Java, probablemente seguirás entendiendo el código. Se copia aquí si sólo desea referirse a ella:

1
2
/** The game logic for a Tic-Tac-Toe game. This model does not have

3
 * an associated User Interface: it is just the game logic.

4
 * 

5
 * The game is represented by a simple 3x3 integer array. A value of

6
 * 0 means the space is empty, 1 means it is an X, 2 means it is an O. 

7
 * 

8
 * @author aarnott

9
 *

10
 */
11
public class TicTacToeModel {
12
	//True if it is the X player’s turn, false if it is the O player’s turn

13
	private boolean playerXTurn;
14
	//The set of spaces on the game grid

15
	private int[][] spaces;
16
	
17
	/** Initialize a new game model. In the traditional Tic-Tac-Toe

18
	 * game, X goes first.

19
	 * 

20
	 */
21
	public TicTacToeModel() {
22
		spaces = new int[3][3]; 
23
		playerXTurn = true;
24
	}
25
	
26
	/** Returns true if it is the X player's turn.

27
	 * 

28
	 * @return

29
	 */
30
	public boolean isPlayerXTurn() {
31
		return playerXTurn;
32
	}
33
	
34
	/** Returns true if it is the O player's turn.

35
	 * 

36
	 * @return

37
	 */
38
	public boolean isPlayerOTurn() {
39
		return !playerXTurn;
40
	}
41
	
42
	/** Places an X on a space specified by the row and column

43
	 * parameters.

44
	 * 

45
	 * Preconditions:

46
	 * -> It must be the X player's turn

47
	 * -> The space must be empty

48
	 * 

49
	 * @param row The row to place the X on

50
	 * @param col The column to place the X on

51
	 */
52
	public void placeX(int row, int col) {
53
		assert(playerXTurn);
54
		assert(spaces[row][col] == 0);
55
		spaces[row][col] = 1;
56
		playerXTurn = false;
57
	}
58
	
59
	/** Places an O on a space specified by the row and column

60
	 * parameters.

61
	 * 

62
	 * Preconditions:

63
	 * -> It must be the O player's turn

64
	 * -> The space must be empty

65
	 * 

66
	 * @param row The row to place the O on

67
	 * @param col The column to place the O on

68
	 */	
69
	public void placeO(int row, int col) {
70
		assert(!playerXTurn);
71
		assert(spaces[row][col] == 0);
72
		spaces[row][col] = 2;
73
		playerXTurn = true;
74
	}
75
	
76
	/** Returns true if a space on the grid is empty (no Xs or Os)

77
	 * 	

78
	 * @param row

79
	 * @param col

80
	 * @return

81
	 */
82
	public boolean isSpaceEmpty(int row, int col) {
83
		return (spaces[row][col] == 0);
84
	}
85
	
86
	/** Returns true if a space on the grid is an X.

87
	 * 	

88
	 * @param row

89
	 * @param col

90
	 * @return

91
	 */
92
	public boolean isSpaceX(int row, int col) {
93
		return (spaces[row][col] == 1);
94
	}
95
	
96
	/** Returns true if a space on the grid is an O.

97
	 * 	

98
	 * @param row

99
	 * @param col

100
	 * @return

101
	 */
102
	public boolean isSpaceO(int row, int col) {
103
		return (spaces[row][col] == 2);
104
	}
105
	
106
	/** Returns true if the X player won the game. That is, if the

107
	 * X player has completed a line of three Xs.

108
	 * 

109
	 * @return

110
	 */
111
	public boolean hasPlayerXWon() {
112
		//Check rows

113
		if(spaces[0][0] == 1 && spaces[0][1] == 1 && spaces[0][2] == 1) return true;
114
		if(spaces[1][0] == 1 && spaces[1][1] == 1 && spaces[1][2] == 1) return true;
115
		if(spaces[2][0] == 1 && spaces[2][1] == 1 && spaces[2][2] == 1) return true;
116
		//Check columns

117
		if(spaces[0][0] == 1 && spaces[1][0] == 1 && spaces[2][0] == 1) return true;
118
		if(spaces[0][1] == 1 && spaces[1][1] == 1 && spaces[2][1] == 1) return true;
119
		if(spaces[0][2] == 1 && spaces[1][2] == 1 && spaces[2][2] == 1) return true;
120
		//Check diagonals

121
		if(spaces[0][0] == 1 && spaces[1][1] == 1 && spaces[2][2] == 1) return true;
122
		if(spaces[0][2] == 1 && spaces[1][1] == 1 && spaces[2][0] == 1) return true;
123
		//Otherwise, there is no line

124
		return false;
125
	}
126
	
127
	/** Returns true if the O player won the game. That is, if the

128
	 * O player has completed a line of three Os.

129
	 * 

130
	 * @return

131
	 */	
132
	public boolean hasPlayerOWon() {
133
		//Check rows

134
		if(spaces[0][0] == 2 && spaces[0][1] == 2 && spaces[0][2] == 2) return true;
135
		if(spaces[1][0] == 2 && spaces[1][1] == 2 && spaces[1][2] == 2) return true;
136
		if(spaces[2][0] == 2 && spaces[2][1] == 2 && spaces[2][2] == 2) return true;
137
		//Check columns

138
		if(spaces[0][0] == 2 && spaces[1][0] == 2 && spaces[2][0] == 2) return true;
139
		if(spaces[0][1] == 2 && spaces[1][1] == 2 && spaces[2][1] == 2) return true;
140
		if(spaces[0][2] == 2 && spaces[1][2] == 2 && spaces[2][2] == 2) return true;
141
		//Check diagonals

142
		if(spaces[0][0] == 2 && spaces[1][1] == 2 && spaces[2][2] == 2) return true;
143
		if(spaces[0][2] == 2 && spaces[1][1] == 2 && spaces[2][0] == 2) return true;
144
		//Otherwise, there is no line

145
		return false;
146
	}
147
	
148
	/** Returns true if all the spaces are filled or one of the players has

149
	 * won the game.

150
	 * 

151
	 * @return

152
	 */
153
	public boolean isGameOver() {
154
		if(hasPlayerXWon() || hasPlayerOWon()) return true;
155
		//Check if all the spaces are filled. If one isn’t the game isn’t over

156
		for(int row = 0; row < 3; row++) {
157
			for(int col = 0; col < 3; col++) {
158
				if(spaces[row][col] == 0) return false;
159
			}
160
		}
161
		//Otherwise, it is a “cat’s game”

162
		return true;
163
	}	
164
165
}

Paso 2: Entender el patrón de comando

El patrón de comandos Command es un patrón de diseño que se utiliza comúnmente con interfaces de usuario para separar las acciones realizadas por botones, menús u otros widgets de las definiciones de código de interfaz de usuario para estos objetos. Este concepto de separar código de acción se puede utilizar para realizar un seguimiento de cada cambio que ocurre en el estado de un juego, y puede utilizar esta información para invertir los cambios.

La versión más básica del patrón de comandos Command es la siguiente interfaz:

1
public interface Command {
2
	public void execute();
3
}

Cualquier acción tomada por el programa que cambie el estado del juego -como colocar una X en un espacio específico- implementará la interfaz de Comando Command . Cuando se toma la acción, se llama al método execute ().

Ahora, probablemente notó que esta interfaz no ofrece la capacidad de deshacer acciones; todo lo que hace es tomar el juego de un estado a otro. La siguiente mejora permitirá implementar acciones para ofrecer capacidad de deshacer.

1
public interface Command {
2
	public void execute();
3
	public void undo();
4
}

El objetivo al implementar un comando Command será hacer que el método undo() invierta cada acción tomada por el método execute. Como consecuencia, el método execute() también podrá proporcionar la capacidad de rehacer una acción.

Esa es la idea básica. Será más claro a medida que implementamos comandos específicos para este juego.


Paso 3: Cree un administrador de comandos

Para agregar una función de deshacer, creará una clase CommandManager. El CommandManager es responsable de rastrear, ejecutar y deshacer implementaciones de comandos Command.

(Recuerde que la interfaz de comandos Command  proporciona los métodos para hacer cambios de un estado de un programa a otro y también invertirlo.)

1
public class CommandManager {
2
    private Command lastCommand;
3
4
    public CommandManager() {}
5
6
    public void executeCommand(Command c) {
7
        c.execute();
8
        lastCommand = c;
9
    }
10
11
    ...
12
13
}

Para ejecutar un comando Command, el CommandManager pasa una instancia de comando Command y ejecutará el comando Command y luego almacenará el comando Command ejecutado más recientemente para referencia posterior.

Agregar la función de deshacer al CommandManager simplemente requiere decirle que deshaga el Comando Command más reciente que se ejecutó.

1
public boolean isUndoAvailable() {
2
    return lastCommand != null;
3
}
4
5
public void undo() {
6
    assert(lastCommand != null);
7
    lastCommand.undo();
8
    lastCommand = null;
9
}

Este código es todo lo que se requiere para tener un CommandManager funcional. Para que funcione correctamente, tendrá que crear algunas implementaciones de la interfaz de comandos Command.


Paso 4: Crear implementaciones de la interfaz de comandos Command

El objetivo del patrón Command para este tutorial es mover cualquier código que cambie el estado del juego tic-tac-toe en una instancia Command. Es decir, el código en los métodos placeX() y placeO() es lo que va a cambiar.

Dentro de la clase TicTacToeModel, agregue dos nuevas clases internas denominadas PlaceXCommand y PlaceOCommand, respectivamente, que implementan la interfaz Command.

1
public class TicTacToeModel {
2
3
	...
4
5
	private class PlaceXCommand implements Command {
6
7
		public void execute() {
8
			...
9
		}
10
11
		public void undo() {
12
			...
13
		}
14
15
	}
16
17
	private class PlaceOCommand implements Command {
18
19
		public void execute() {
20
			...
21
		}
22
23
		public void undo() {
24
			...
25
		}
26
27
	}
28
29
}

El trabajo de una implementación de comandos Command es almacenar un estado y tener lógica para la transición a un nuevo estado resultante de la ejecución del comando Command o para volver al estado inicial antes de ejecutar el comando Command. Hay dos formas sencillas de lograr esta tarea.

  1. Almacene todo el estado anterior y el estado siguiente. Establezca el estado actual del juego al siguiente estado cuando se ejecuta execute() y configure el estado actual del juego al estado anterior almacenado cuando se invoca undo().    
  2. Almacene sólo la información que cambia entre estados. Cambie sólo esta información almacenada cuando se ejecuta con execute() o se deshace con undo()
1
//Option 1: Storing the previous and next states

2
private class PlaceXCommand implements Command {
3
    private TicTacToeModel model;
4
    //

5
    private int[][] previousGridState;
6
    private boolean previousTurnState;
7
    private int[][] nextGridState;
8
    private boolean nextTurnState;    
9
    //

10
    private PlaceXCommand (TicTacToeModel model, int row, int col) {
11
        this.model = model;
12
        //

13
        previousTurnState = model.playerXTurn;
14
        //Copy the entire grid for both states

15
        previousGridState = new int[3][3];
16
        nextGridState = new int[3][3];
17
        for(int i = 0; i < 3; i++) {
18
            for(int j = 0; j < 3; j++) {
19
                //This is allowed because this class is an inner

20
                //class. Otherwise, the model would need to 

21
                //provide array access somehow.

22
                previousGridState[i][j] = m.spaces[i][j];
23
                nextGridState[i][j] = m.spaces[i][j];                
24
            }
25
        }
26
        //Figure out the next state by applying the placeX logic

27
        nextGridState[row][col] = 1;
28
        nextTurnState = false;
29
    }
30
    //

31
    public void execute() {
32
        model.spaces = nextGridState;
33
        model.playerXTurn = nextTurnState;
34
    }
35
    //

36
    public void undo() {
37
        model.spaces = previousGridState;
38
        model.playerXTurn = previousTurnState;
39
    }
40
}

La primera opción es un poco derrochadora, pero eso no significa que sea un mal diseño. El código es sencillo y, a menos que la información del estado es muy grande la cantidad de residuos no será algo de qué preocuparse.

Verá que, en el caso de este tutorial, la segunda opción es mejor, pero este enfoque no siempre será el mejor para cada programa. Más a menudo que no, sin embargo, la segunda opción será el camino a seguir.

1
//Option 2: Storing only the changes between states

2
private class PlaceXCommand implements Command {
3
    private TicTacToeModel model;
4
    private int previousValue;
5
    private boolean previousTurn;
6
    private int row;
7
    private int col;
8
    //

9
    private PlaceXCommand(TicTacToeModel model, int row, int col) {
10
        this.model = model;
11
        this.row = row;
12
        this.col = col;
13
        //Copy the previous value from the grid

14
        this.previousValue = model.spaces[row][col];
15
        this.previousTurn = model.playerXTurn;
16
    }
17
    //

18
    public void execute() {
19
        model.spaces[row][col] = 1;		
20
        model.playerXTurn = false;
21
    }
22
    //	

23
    public void undo() {
24
        model.spaces[row][col] = previousValue;
25
        model.playerXTurn = previousTurn;
26
    }		
27
}

La segunda opción sólo almacena los cambios que ocurren, en lugar de todo el estado. En el caso de tic-tac-toe, es más eficiente y no notablemente más complejo usar esta opción.

La clase interna de PlaceOCommand está escrita de una manera similar - tiene que escribirlo usted mismo!


Paso 5: Poner todo junto

Para poder utilizar las implementaciones Command, PlaceXCommand y PlaceOCommand, deberá modificar la clase TicTacToeModel. La clase debe hacer uso de un CommandManager y debe usar instancias de comandoCommand en lugar de aplicar acciones directamente.

1
public class TicTacToeModel {
2
    private CommandManager commandManager;
3
    //

4
    ...
5
    //

6
    public TicTacToeModel() {
7
        ...
8
        //

9
        commandManager = new CommandManager();
10
    }
11
    //

12
    ...
13
    //

14
    public void placeX(int row, int col) {
15
        assert(playerXTurn);
16
        assert(spaces[row][col] == 0);
17
        commandManager.executeCommand(new PlaceXCommand(this, row, col));
18
    }
19
    //

20
    public void placeO(int row, int col) {
21
        assert(!playerXTurn);
22
        assert(spaces[row][col] == 0);
23
        commandManager.executeCommand(new PlaceOCommand(this, row, col));
24
    }
25
    //

26
    ...
27
}

La clase TicTacToeModel funcionará exactamente igual que antes de los cambios, pero también puede exponer la función de deshacer. Agregue un método undo() al modelo y también agregue un método de control canUndo para que la interfaz de usuario lo utilice en algún momento.

1
public class TicTacToeModel {
2
    //

3
    ...
4
    //

5
    public boolean canUndo() {
6
        return commandManager.isUndoAvailable();
7
    }
8
    //

9
    public void undo() {
10
        commandManager.undo();
11
    }
12
13
}

¡Ahora tienes un modelo de juego tic-tac-toe completamente funcional que soporta deshacer!


Paso 6: profundicelo más

Con algunas pequeñas modificaciones en CommandManager, puede agregar soporte para operaciones de rehacer, así como un número ilimitado de deshacer y rehacer.

El concepto detrás de una característica de rehacer es prácticamente lo mismo que una función de deshacer. Además de almacenar el último comando Command ejecutado, también almacena el último comando Command  que se anuló. Almacena ese comando Command cuando se llama a un deshacer y lo borra cuando se ejecuta un comando Command.

1
public class CommandManager {
2
3
    private Command lastCommandUndone;
4
5
    ...
6
7
    public void executeCommand(Command c) {
8
        c.execute();
9
        lastCommand = c;
10
        lastCommandUndone = null;
11
    }
12
13
    public void undo() {
14
        assert(lastCommand != null);
15
        lastCommand.undo();
16
        lastCommandUndone = lastCommand;
17
        lastCommand = null;
18
    }
19
20
    public boolean isRedoAvailable() {
21
        return lastCommandUndone != null;
22
    }
23
24
    public void redo() {
25
        assert(lastCommandUndone != null);
26
        lastCommandUndone.execute();
27
        lastCommand = lastCommandUndone;
28
        lastCommandUndone = null;
29
    }
30
}

La adición de múltiples deshacer y redos es una cuestión de almacenar una pila de acciones que se pueden deshacer y rehacer. Cuando se ejecuta una nueva acción, se agrega a la pila de deshacer y la pila de rehacer se borra. Cuando se deshace una acción, se agrega a la pila de rehacer y se elimina de la pila de deshacer. Cuando se vuelve a realizar una acción, se elimina de la pila de rehacer y se agrega a la pila de deshacer.

Undo and redo in game development with the command patternUndo and redo in game development with the command patternUndo and redo in game development with the command pattern

La imagen anterior muestra un ejemplo de las pilas en acción. La pila de rehacer tiene dos elementos de comandos que ya se han deshecho. Cuando se ejecutan nuevos comandos, PlaceX(0,0) y PlaceO(0,1), la pila de rehacer se borra y se agregan a la pila de deshacer. Cuando se deshace un PlaceO(0,1), se quita de la parte superior de la pila de deshacer y se coloca en la pila de rehacer.

Así es como se ve en el código:

1
public class CommandManager {
2
3
    private Stack<Command> undos = new Stack<Command>();
4
    private Stack<Command> redos = new Stack<Command>();
5
6
    public void executeCommand(Command c) {
7
        c.execute();
8
        undos.push(c);
9
        redos.clear();
10
    }
11
12
    public boolean isUndoAvailable() {
13
        return !undos.empty();
14
    }
15
16
    public void undo() {
17
        assert(!undos.empty());
18
        Command command = undos.pop();
19
        command.undo();
20
        redos.push(command);
21
    }
22
23
    public boolean isRedoAvailable() {
24
        return !redos.empty();
25
    }
26
27
    public void redo() {
28
        assert(!redos.empty());
29
        Command command = redos.pop();
30
        command.execute();
31
        undos.push(command);
32
    }
33
}

Ahora tienes un modelo de juego tic-tac-toe que puede deshacer acciones hasta el principio del juego y volver a hacerlas.

Si quieres ver cómo encaja todo esto, toma la descarga de la fuente final, que contiene el código completo de este tutorial.


Conclusión

Es posible que haya notado que el CommandManager final que escribió funcionará para cualquier implementación de Command. Esto significa que puede codificar un CommandManager en su idioma favorito, crear algunas instancias de la interfaz de comandos Command y tener un sistema completo preparado para deshacer / rehacer. La función de deshacer puede ser una gran manera de permitir a los usuarios explorar su juego y cometer errores sin sentirse comprometidos con las malas decisiones.

¡Gracias por interesarnos en este tutorial!

Como algo más de reflexión, considere lo siguiente: el patrón de comandos Command junto con CommandManager le permiten realizar un seguimiento de cada cambio de estado durante la ejecución de su juego. Si guarda esta información, puede crear repeticiones de la ejecución del programa.