I'm writing an assistant for the tabletop RPG Pathfinder in Java. I already have a few building blocks and decided to make a small side-project to play around the Command pattern. I know that there are controversies about using them, so I am mostly trying to get the main ideas behind the pattern to introduce undo/redo rather than implementing it religiously.
This project is a simplified turn-based battle manager. In the model, I have actors, who can move and attack. When they have no health points left, they die. These actors are my receivers. I have two commands: Attack, and Move. Finally, I also have the command Invoker, a BattleManager which handles turns and passes orders to the Invoker, and the Main class to handle I/O with the user. I have not separated the different classes into packages for the sake of simplicity.
My main question is: have I properly understood the concept of the Command pattern?
The point that worries me is that to implement the attack command, I have an injure
method in Actor
, an Attack
class, an attack
method in BattleManager and I have to handle the attack input in Main
. Each part seems reasonable, because each has a separate responsibility, but I fear I have made things way more complex than necessary so I'm interested in a sanity check.
I also welcome feedback on every aspect of the code before I expand on it and give it a much more interesting data model, commands and user interface.
Actor.java
/**
* Represents an actor, capable of taking actions.
*/
public class Actor {
public final String name;
private int xPosition;
private int yPosition;
/** Number of Health points of the actor. */
private int currentHp;
public final int damage;
public Actor(String name, int initialHp, int damage, int xPos, int yPos){
this.name = name;
this.currentHp = initialHp;
this.damage = damage;
this.xPosition = xPos;
this.yPosition = yPos;
}
/** Return the current number of health points of the actor. */
public int getCurrentHp(){
return this.currentHp;
}
/**
* Subtract damages to the health points of the actor.
* @param damage number of health points to deduct. If negative, the actor is healed.
*/
public void injure(int damage){
this.currentHp -= damage;
}
public boolean isAlive(){
return (this.currentHp > 0);
}
/**
* Move to a new position.
* @param x the new x-coordinate of the actor.
* @param y the new y-coordinate of the actor.
*/
public void move(int x, int y){
this.xPosition = x;
this.yPosition = y;
}
public int getXPosition(){
return this.xPosition;
}
public int getYPosition(){
return this.yPosition;
}
}
Command.java
/**
* Interface implemented by all actions that can be undone.
*/
public interface Command {
/**
* Perform the command.
*/
public void execute();
/**
* Undo the command. This assumes that the global state is exactly as it was right after
* {@link #execute()} was called.
*/
public void undo();
}
Attack.java
/**
* Allows an actor to attack another.
*/
public class Attack implements Command {
private final int damage;
private final Actor target;
public Attack(Actor target, int damage){
this.damage = damage;
this.target = target;
}
@Override
public void execute(){
target.injure(damage);
}
@Override
public void undo(){
target.injure(-damage);
}
}
Move.java
/**
* Allows an actor to move.
*/
public class Move implements Command {
private final int[] origin;
private final int[] endpoint;
private final Actor actor;
public Move(Actor actor, int nextX, int nextY){
this.origin = new int[]{actor.getXPosition(), actor.getYPosition()};
this.endpoint = new int[]{nextX, nextY};
this.actor = actor;
}
@Override
public void execute(){
actor.move(endpoint[0], endpoint[1]);
}
@Override
public void undo(){
actor.move(origin[0], origin[1]);
}
}
Invoker.java
import java.util.Deque;
import java.util.LinkedList;
/**
* Invoker of the command design pattern. Handles the execution, undoing and redoing of commands.
*/
public class Invoker {
private final Deque<Command> executedCommands = new LinkedList<>();
private final Deque<Command> undoneCommands = new LinkedList<>();
private final static int MAX_UNDO_STACK_SIZE = 3;
Invoker(){}
/**
* Utility method to factor the internal workings of archiving a command.
* @param command Command executed or undone.
* @param receiver Queue in which the command must be archived.
*/
private static void commandArchiver(Command command, Deque<Command> receiver){
receiver.addFirst(command);
if (receiver.size() > MAX_UNDO_STACK_SIZE){
receiver.removeLast();
}
}
public void execute(Command command){
command.execute();
commandArchiver(command, executedCommands);
undoneCommands.clear();
}
public void undo(){
Command command = executedCommands.removeFirst();
command.undo();
commandArchiver(command, undoneCommands);
}
public boolean canUndo(){
return executedCommands.size() > 0;
}
public void redo(){
Command command = undoneCommands.removeFirst();
command.execute();
commandArchiver(command, executedCommands);
}
public boolean canRedo(){
return undoneCommands.size() > 0;
}
}
BattleManager.java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* Manages the course of a battle. Keeps track of actors in presence and turns, and determines when
* the battle is won and by whom.
*/
public class BattleManager {
private final List<Actor> actors = new ArrayList<>();
private final Invoker invoker = new Invoker();
private int turnIndex = 0;
private Actor winner = null;
public BattleManager(Actor... actors){
this.actors.addAll(Arrays.asList(actors));
}
public List<Actor> getActors(){
return Collections.unmodifiableList(actors);
}
/** Return the index of the actor currently playing. */
public int getCurrentActorIndex(){
return turnIndex;
}
/** Return the actor that won the battle, or null if no actor has won yet. */
public Actor isWon(){
return winner;
}
/** Move the actor currently playing to a new position. */
public String move(int x, int y){
Actor actor = actors.get(turnIndex);
Actor collision = null;
for (Actor candidate:actors){
if (candidate.getXPosition() == x && candidate.getYPosition() == y){
collision = candidate;
break;
}
}
String result;
if (collision == null){
result = actor.name + " moves from [" + actor.getXPosition() + ", "
+ actor.getYPosition() + "] to [" + x + ", " + y + "].";
invoker.execute(new Move(actor, x, y));
} else {
result = actor.name + " tried to collide with " + collision.name
+ ", could not move.";
invoker.execute(new Move(actor, actor.getXPosition(), actor.getYPosition()));
}
turnIndex = (turnIndex+1) % actors.size();
return result;
}
/** Make the actor currently playing attack another actor. */
public String attack(Actor target){
Actor attacker = actors.get(turnIndex);
int damage;
String result;
//check if in range
if (Math.abs(attacker.getXPosition() - target.getXPosition()) <= 1
&& Math.abs(attacker.getYPosition() - target.getYPosition()) <= 1){
damage = attacker.damage;
result = attacker.name + " attacks " + target.name + " for " + damage
+ " damage.";
} else {
damage = 0;
result = attacker.name + " missed when attacking " + target.name;
}
invoker.execute(new Attack(target, damage));
if (!target.isAlive()){
winner = attacker;
}
turnIndex = (turnIndex+1) % actors.size();
return result;
}
public void undo(){
turnIndex = (turnIndex+actors.size()-1) % actors.size();
invoker.undo();
}
public boolean canUndo(){
return invoker.canUndo();
}
public void redo(){
turnIndex = (turnIndex+1) % actors.size();
invoker.redo();
}
public boolean canRedo(){
return invoker.canRedo();
}
}
Main.java
import java.util.Scanner;
/**
* Client application. Handles I/O with the user.
*/
public class Main {
/**
* Run the game.
* @param args ignored
*/
public static void main(String[] args) {
Actor player1 = new Actor("Player1", 10, 2, 0, 0);
Actor player2 = new Actor("Player2", 15, 1, 3, 6);
Scanner scanner = new Scanner(System.in);
BattleManager manager = new BattleManager(player1, player2);
System.out.println("Battle beginning");
while (manager.isWon() == null){
System.out.println(getDisplayableStatus(manager));
handleTurn(manager, scanner);
}
System.out.println(manager.isWon().name + " has won the battle");
}
/**
* Format the current state of the battle to display it to the user.
* @param manager BattleManager handling the battle.
* @return a String representing the state of the players.
*/
public static String getDisplayableStatus(BattleManager manager){
StringBuilder builder = new StringBuilder("Battle status:\n");
for (Actor actor:manager.getActors()){
builder.append("Actor ").append(actor.name).append("\t\tPosition: ")
.append("[").append(actor.getXPosition()).append(", ").append(actor.getYPosition())
.append("]\t\tHealth points:").append(actor.getCurrentHp()).append("\n");
}
return builder.toString();
}
/**
* Handle the course of a turn. Handle user input and perform the selected action.
* @param manager BattleManager handling the battle.
* @param scanner Scanner used to parse the user input.
*/
public static void handleTurn(BattleManager manager, Scanner scanner){
System.out.println("It is " + manager.getActors().get(manager.getCurrentActorIndex()).name
+ "'s turn. What will he do?");
boolean search = true;
String result;
while(search){
System.out.println(possibleOptions(manager));
String input = scanner.next();
search = false;
switch (input){
case "A":
result = manager.attack(manager.getActors().get(
1-manager.getCurrentActorIndex()));
break;
case "M":
result = manager.move(getMoveDestination(scanner, "x"),
getMoveDestination(scanner, "y"));
break;
case "U":
if (manager.canUndo()){
manager.undo();
result = "Last command undone";
} else {
result = "No command to undo";
search = true;
}
break;
case "R":
if (manager.canRedo()){
manager.redo();
result = "Last undone commande redone";
} else {
result = "No command to redo";
search = true;
}
break;
default:
result = "Could not interpret " + input + " as a command";
search = true;
}
System.out.println(result);
}
}
/**
* Format the options available to a user.
* @param manager BattleManager handling the battle.
* @return a String presenting all the available options to the user.
*/
public static String possibleOptions(BattleManager manager){
StringBuilder builder = new StringBuilder(
"Possible options are:\nA: attack the other actor.\nM: move to a new position.");
if (manager.canUndo()){
builder.append("\nU: undo the last command.");
}
if (manager.canRedo()){
builder.append("\nR: redo the last command.");
}
return builder.toString();
}
/**
* Handle the acquisition of a coordinate for a {@link Move} action from the user.
* @param scanner Scanner used to parse the user input.
* @param coordinate Which of the coordinates to ask from the user.
* @return
*/
public static int getMoveDestination(Scanner scanner, String coordinate){
System.out.println("Enter the " + coordinate + "-coordinate of your destination");
if (!scanner.hasNextInt()){
do{
System.out.println("Could not parse " + scanner.next()
+ " as an integer, please try again");
} while (!scanner.hasNextInt());
}
return scanner.nextInt();
}
}