Deje a sus jugadores deshacer sus errores en el juego con el patrón de comandos
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.
¿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.
- 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 invocaundo(). - 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 conundo()
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.


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.



