4
\$\begingroup\$

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();
    }
}
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

Some of your code formatting is a little bit claustrophobic - you're missing a blank line between several of your functions in Actor, for example.

It's good that you've started writing some javadocs, but comments like this:

    /** Return the current number of health points of the actor. */

are redundant and are less preferable to there being no docstring at all. In other words, getCurrentHp has a good name and its function is obvious. As a counter-example, the docstring for injure is probably good as-is.

The parens at the return of isAlive should be removed.

Since Command is an interface, public is implied and those keywords should be removed from its methods.

Attack is a simple, immutable class that should be converted to a record.

I don't see the benefit to Move keeping the origin and endpoint coordinates as arrays. Why not store them as separate x/y variables, since that's how they're always used?

BattleManager.move() mixes two concerns: performing the move, and describing the move in a string. These should be separated.

Repeated-concatenation like

actor.name + " moves from [" + actor.getXPosition() + ", " 
                + actor.getYPosition() + "] to [" + x + ", " + y + "].";

should be replaced with a string format call.

Main has all-static methods. Consider reworking this class to have main() be the only static method; have it instantiate its own Main class; store your scanner as a member on the class.

Consider replacing your handleTurn switch with new expression-switch syntax.

Your Main.possibleOptions string builder is non-ideal since it hard-codes a system-specific newline. Consider replacing this with a StringWriter+PrintWriter which has better format and print methods.

You have inconsistent use of this.. I'm in the "only use it when required" camp, and in this style it would be deleted from many of the places you've used it currently.

\$\endgroup\$
1
  • \$\begingroup\$ Thank you for the time you took reviewing my code. I cannot take immediately into account the record and expression-switch syntax because I am still using Java11 but thank you for hinting them to me. Apart from the points you mention, would you consider my implementation of undo/redo sane enough, especially considering the number of places where part of each possible action will have to be defined? \$\endgroup\$
    – Anab
    Commented Feb 27, 2022 at 20:42

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.