8
\$\begingroup\$

First version: Go (board game) in Java

What's new:

  • I now use (as advised) HashMap<GoPoint, StoneColor> where GoPoint is my "coordinate" class and StoneColor is an enum.
  • Game allows only legal moves.
  • Player can pass (skip turn).
  • Player can choose the size of game board.
  • Code is more readable (I hope).
  • Better separation of view and model.
  • Everything should work and you can actually play.

What's missing:

  • Score counting. I'm not even sure wherever I'll implement it. Any advice on how to do it (that's not too complicated if possible) is welcome.
  • A.I. This is not going to happen in near future. I definitely want to try building A.I. but I'll try it with something simpler.

Anything that could improve my code is welcome. Is it readable? Should I do something diferently?...

GoMain

package go;

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.event.ActionEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;

import javax.swing.AbstractAction;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JPanel;

/**
 * Builds UI and starts the game.
 *
 */
public class GoMain {

    public static final String TITLE = "Simple Go";
    public static final int OUTSIDE_BORDER_SIZE = 25;

    private StartDialog startDialog;

    public static void main(String[] args) {
        new GoMain().init();
    }

    private void init() {
        startDialog = new StartDialog(this);
        startDialog.pack();
        startDialog.setLocationByPlatform(true);
        startDialog.setVisible(true);
    }

    public void startGame(int size) {
        JFrame f = new JFrame();
        f.setTitle(TITLE);

        f.add(createMainContainer(size));

        f.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
        f.addWindowListener(new WindowAdapter() {
            @Override
            public void windowClosing(WindowEvent e) {
                super.windowClosing(e);
                startDialog.setVisible(true);
            }
        });
        f.pack();
        f.setResizable(false);
        f.setLocationByPlatform(true);
        f.setVisible(true);
    }

    private JPanel createMainContainer(int size) {
        JPanel container = new JPanel();
        container.setBackground(Color.GRAY);
        container.setLayout(new BorderLayout());
        container.setBorder(BorderFactory.createEmptyBorder(
                OUTSIDE_BORDER_SIZE, OUTSIDE_BORDER_SIZE, OUTSIDE_BORDER_SIZE,
                OUTSIDE_BORDER_SIZE));

        GameBoard board = new GameBoard(size);
        container.add(board, BorderLayout.CENTER);
        container.add(createBottomContainer(board), BorderLayout.SOUTH);

        return container;
    }

    private JPanel createBottomContainer(GameBoard board) {
        JPanel bottomContainer = new JPanel();
        JButton passButton = new JButton(new AbstractAction("Pass") {

            @Override
            public void actionPerformed(ActionEvent e) {
                board.getGameState().pass();
            }
        });
        bottomContainer.add(passButton, BorderLayout.SOUTH);
        return bottomContainer;
    }

}

GameBoard

package go;

import go.GameState.StoneColor;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;

import javax.swing.JPanel;

/**
 * Provides I/O.
 * 
 *
 */
public class GameBoard extends JPanel {

    public static final int TILE_SIZE = 40;
    public static final int BORDER_SIZE = TILE_SIZE;

    public final int size;
    private final GameState gameState;

    /**
     * 
     * @param size
     *            number of rows/columns
     */
    public GameBoard(int size) {
        this.size = size;
        gameState = new GameState(size);
        this.setBackground(Color.ORANGE);

        this.addMouseListener(new MouseAdapter() {

            @Override
            public void mouseReleased(MouseEvent e) {
                // Converts to float for float division and then rounds to
                // provide nearest intersection.
                int row = Math.round((float) (e.getY() - BORDER_SIZE)
                        / TILE_SIZE);
                int col = Math.round((float) (e.getX() - BORDER_SIZE)
                        / TILE_SIZE);

                // DEBUG INFO
                // System.out.println(String.format("y: %d, x: %d", row, col));

                if (gameState.playAt(row, col)) {
                    repaint();
                }
            }
        });
    }

    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);

        Graphics2D g2 = (Graphics2D) g;
        g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                RenderingHints.VALUE_ANTIALIAS_ON);

        g2.setColor(Color.BLACK);
        drawRows(g2);
        drawColumns(g2);
        drawStones(g2);
        highlightLastMove(g2);
    }

    private void highlightLastMove(Graphics2D g2) {
        GoPoint lastMove = gameState.getLastMove();
        if (lastMove != null) {
            g2.setColor(Color.RED);
            g2.drawOval(mapToBoard(lastMove.getCol()) - TILE_SIZE / 2,
                    mapToBoard(lastMove.getRow()) - TILE_SIZE / 2, TILE_SIZE,
                    TILE_SIZE);
        }
    }

    private void drawStones(Graphics2D g2) {
        for (GoPoint gp : gameState.getAllPoints()) {
            StoneColor stoneColor = gameState.getColor(gp);
            if (stoneColor != StoneColor.NONE) {
                if (stoneColor == StoneColor.BLACK) {
                    g2.setColor(Color.BLACK);
                } else {
                    g2.setColor(Color.WHITE);
                }
                g2.fillOval(mapToBoard(gp.getCol()) - TILE_SIZE / 2,
                        mapToBoard(gp.getRow()) - TILE_SIZE / 2, TILE_SIZE,
                        TILE_SIZE);
            }
        }
    }

    private void drawRows(Graphics2D g2) {
        for (int i = 0; i < size; i++) {
            g2.drawLine(mapToBoard(0), mapToBoard(i), mapToBoard(size - 1),
                    mapToBoard(i));
        }
    }

    private void drawColumns(Graphics2D g2) {
        for (int i = 0; i < size; i++) {
            g2.drawLine(mapToBoard(i), mapToBoard(0), mapToBoard(i),
                    mapToBoard(size - 1));
        }
    }

    /**
     * Returns x/y coordinate of column/row
     * 
     * @param i row/column
     * @return x/y coordinate of column/row
     */
    private int mapToBoard(int i) {
        return i * TILE_SIZE + BORDER_SIZE;
    }

    @Override
    public Dimension getPreferredSize() {
        return new Dimension((size - 1) * TILE_SIZE + BORDER_SIZE * 2,
                (size - 1) * TILE_SIZE + BORDER_SIZE * 2);
    }

    public GameState getGameState() {
        return gameState;
    }

}

GameState

package go;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Set;

import javax.swing.JOptionPane;

/**
 * Provides game logic.
 * 
 *
 */
public class GameState {

    /**
     * Black/white stone
     * 
     *
     */
    public enum StoneColor {
        BLACK, WHITE, NONE
    }

    private final int size;

    private GoPoint lastMove;
    private boolean itsBlacksTurn;
    private HashMap<GoPoint, StoneColor> stones;
    // Previous position after black played. For "ko rule".
    private HashMap<GoPoint, StoneColor> previousBlackPosition;
    private HashMap<GoPoint, StoneColor> previousWhitePosition;
    private boolean passedPreviously;
    /**
     * True if any stone was removed this turn.
     */
    private boolean removedStone;

    public GameState(int size) {
        this.size = size;
        // Black always starts
        itsBlacksTurn = true;
        lastMove = null;
        previousBlackPosition = new HashMap<>();
        previousWhitePosition = new HashMap<>();
        populateBoard();
    }

    /**
     * Initializes the game map with empty GoPoint(s).
     */
    private void populateBoard() {
        stones = new HashMap<>();
        for (int row = 0; row < size; row++) {
            for (int col = 0; col < size; col++) {
                stones.put(new GoPoint(row, col), StoneColor.NONE);
            }
        }
    }

    /**
     * Passes and shows score if the game ended. TODO
     * 
     */
    public void pass() {
        if (passedPreviously) {
            // TODO: scoring
            JOptionPane.showMessageDialog(null, "Game over.");
        }
        savePosition();
        lastMove = null;
        passedPreviously = true;

    }

    /**
     * Processes input and handles game logic. Returns false if move is invalid.
     * 
     * @param row
     * @param col
     * @return false if move is invalid
     */
    public boolean playAt(int row, int col) {
        if (row >= size || col >= size || row < 0 || col < 0) {
            return false;
        }

        GoPoint newStone = getPointAt(row, col);

        if (isOccupied(newStone)) {
            return false;
        }
        removedStone = false;

        addStone(newStone);

        // Suicide is legal (i.e. you don't actually commit suicide) if you
        // remove enemy stones with it.
        if (!removedStone && isSuicide(newStone)) {
            return false;
        }

        // "ko rule": previous position can't be repeated
        if ((itsBlacksTurn && previousBlackPosition.equals(stones))
                || (!itsBlacksTurn && previousWhitePosition.equals(stones))) {
            System.out.println("true");
            stones = previousBlackPosition;
            return false;
        }

        savePosition();

        changePlayer();
        lastMove = newStone;

        return true;

    }

    /**
     * Saves position so we can check violations of "ko rule".
     */
    private void savePosition() {
        if (itsBlacksTurn) {
            previousBlackPosition = new HashMap<>(stones);
        } else {
            previousWhitePosition = new HashMap<>(stones);
        }
    }

    private boolean isOccupied(GoPoint gp) {
        return stones.get(gp) != StoneColor.NONE;
    }

    private GoPoint getPointAt(int row, int col) {
        return new GoPoint(row, col);
    }

    private void changePlayer() {
        itsBlacksTurn = !itsBlacksTurn;
    }

    public Iterable<GoPoint> getAllPoints() {
        return stones.keySet();
    }

    public StoneColor getColor(GoPoint gp) {
        return stones.get(gp);
    }

    /**
     * Returns location of last move or null.
     * 
     * @return
     */
    public GoPoint getLastMove() {
        return lastMove;
    }

    /**
     * Returns true (and removes the Stone) if the move is suicide. You need to
     * actually add the stone first.
     * 
     * @param gp
     * @return true if the move is suicide
     */
    private boolean isSuicide(GoPoint gp) {
        if (isDead(gp, new HashSet<GoPoint>())) {
            removeStone(gp);
            return true;
        }
        return false;
    }

    /**
     * Adds Stone and removes dead neighbors.
     * 
     * @param gp
     */
    private void addStone(GoPoint gp) {
        StoneColor stoneColor;
        if (itsBlacksTurn) {
            stoneColor = StoneColor.BLACK;
        } else {
            stoneColor = StoneColor.WHITE;
        }

        stones.put(gp, stoneColor);

        for (GoPoint neighbor : getNeighbors(gp)) {
            removeIfDead(neighbor);
        }

    }

    private void removeStone(GoPoint gp) {
        stones.put(gp, StoneColor.NONE);
    }

    private Set<GoPoint> getNeighbors(GoPoint gp) {
        Set<GoPoint> neighbors = new HashSet<>();

        if (gp.getRow() > 0) {
            neighbors.add(getPointAt(gp.getRow() - 1, gp.getCol()));
        }
        if (gp.getRow() < size - 1) {
            neighbors.add(getPointAt(gp.getRow() + 1, gp.getCol()));
        }
        if (gp.getCol() > 0) {
            neighbors.add(getPointAt(gp.getRow(), gp.getCol() - 1));
        }
        if (gp.getCol() < size - 1) {
            neighbors.add(getPointAt(gp.getRow(), gp.getCol() + 1));
        }
        return neighbors;
    }

    /**
     * Removes all stones with 0 liberties.
     * 
     * @param gp
     *            starting point
     * 
     */
    private void removeIfDead(GoPoint gp) {
        Set<GoPoint> searchedPoints = new HashSet<>();
        if (isDead(gp, searchedPoints)) {
            // Starting points needs to be added (otherwise it works only with
            // chains of 2+ stones).
            searchedPoints.add(gp);
            if (!searchedPoints.isEmpty()) {
                removedStone = true;
            }
            for (GoPoint toRemove : searchedPoints) {
                removeStone(toRemove);
            }
        }
    }

    /**
     * Checks wherever given stone is dead. Checks the whole chain (all
     * connected stones).
     * 
     * Starting stone needs to be added explicitly. If it finds false then
     * nothing is dead and we want to end quickly as possible. Otherwise it
     * needs to CONTINUE CHECKING. Some stones may seem dead because we're
     * trying to check every stone only once. See below.
     * 
     * <pre>
     * Image situation:
     *  (B, W - black, white)
     *  (W3 - last move)
     *  B|W1|B
     *  B|W2|B
     *  B|W3|B
     * </pre>
     * 
     * Let's say player places W3 so we checks it's neighbors. It starts with W2
     * so we check's W2's neighbors. We can't check north, east and west are
     * filled. But we also can't check south because we don't want to check the
     * stone twice to prevent looping. So W1 is dead. This doesn't matter if we
     * continue checking because W3 (and W2) still return false.
     * 
     * @param gp
     *            starting point
     * @param searchedPoints
     *            set containing already searched stones (of the same color as
     *            starting point)
     * @return false if given stone is alive, but not necessarily true if given
     *         stone is dead (see full description)
     */
    private boolean isDead(GoPoint gp, Set<GoPoint> searchedPoints) {
        for (GoPoint neighbor : getNeighbors(gp)) {
            if (getColor(neighbor) == StoneColor.NONE) {
                return false;
            }
            if (getColor(neighbor) == getColor(gp)
                    && !searchedPoints.contains(neighbor)) {
                /*
                 * We add only neighbors that are stones of the same color
                 * because we can afford to check other neighbors more that once
                 */
                searchedPoints.add(neighbor);
                if (!isDead(neighbor, searchedPoints)) {
                    return false;
                }
            }
        }
        return true;
    }
}

GoPoint

package go;

public final class GoPoint {

    private final int row;
    private final int col;

    public GoPoint(int row, int col) {
        this.row = row;
        this.col = col;
    }

    public int getRow() {
        return row;
    }

    public int getCol() {
        return col;
    }

    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + col;
        result = prime * result + row;
        return result;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        GoPoint other = (GoPoint) obj;
        if (col != other.col)
            return false;
        if (row != other.row)
            return false;
        return true;
    }

    @Override
    public String toString() {
        return "GoPoint [row=" + row + ", col=" + col + "]";
    }

}

StartDialog

package go;

import java.awt.Font;
import java.awt.Frame;
import java.awt.GridLayout;
import java.awt.event.ActionEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;

import javax.swing.AbstractAction;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JOptionPane;
import javax.swing.JPanel;

/**
 * Dialog that lets user pick size and starts the game.
 *
 */
public class StartDialog extends JDialog {

    private static final String TITLE = "Choose size";
    private static final int BUTTON_TEXT_SIZE = 30;
    private static final Font BUTTON_FONT = new Font(null, Font.PLAIN,
            BUTTON_TEXT_SIZE);
    private static final int GAP_SIZE = 10;

    private static final int FIRST_OPTION = 9;
    private static final int SECOND_OPTION = 19;
    private static final String CUSTOM_OPTION = "Custom";

    public StartDialog(GoMain goMain) {
        super((Frame) null, TITLE);
        this.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
        this.addWindowListener(new WindowAdapter() {
            @Override
            public void windowClosing(WindowEvent e) {
                super.windowClosing(e);
                System.exit(0);
            }
        });

        JPanel container = new JPanel(new GridLayout(1, 1, GAP_SIZE, GAP_SIZE));
        container.add(createOptionButton(FIRST_OPTION, goMain, this));
        container.add(createOptionButton(SECOND_OPTION, goMain, this));
        JButton customSizeBtn = new JButton(new AbstractAction(CUSTOM_OPTION) {

            @Override
            public void actionPerformed(ActionEvent e) {
                String sizeString = JOptionPane.showInputDialog("Custom size:");
                try {
                    int size = Integer.parseInt(sizeString);
                    if (size > 1 && size < 30) {
                        goMain.startGame(size);
                    } else {
                        throw new IllegalArgumentException();
                    }
                } catch (IllegalArgumentException ex) {
                    JOptionPane
                            .showMessageDialog(
                                    null,
                                    "Invalid input. Please enter a number between 1 and 30.");
                }
            }
        });
        customSizeBtn.setFont(BUTTON_FONT);
        container.add(customSizeBtn);
        add(container);
    }

    private JButton createOptionButton(int option, GoMain goMain,
            StartDialog parent) {
        JButton optionButton = new JButton(new AbstractAction(
                String.valueOf(option)) {

            @Override
            public void actionPerformed(ActionEvent e) {
                parent.setVisible(false);
                goMain.startGame(option);
            }
        });
        optionButton.setFont(BUTTON_FONT);
        return optionButton;
    }

}
\$\endgroup\$
  • 1
    \$\begingroup\$ One thing you could do to make reviews easier is put all the code on something like GitHub, so that instead of manually copy/pasting all of the code into a project, it's as easy as git clone [url]. \$\endgroup\$ – Nic Hartley Jun 24 '15 at 14:58
5
\$\begingroup\$

Just some comments on your GoPoint class...

  • Good: it's a final class with final fields.
  • Not so good:

    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        GoPoint other = (GoPoint) obj;
        if (col != other.col)
            return false;
        if (row != other.row)
            return false;
        return true;
    }
    

    This can be simplied as as just:

    @Override
    public boolean equals(Object obj) {
        if (obj instanceof GoPoint) {
            GoPoint o = (GoPoint) obj;
            return col == o.col && row == o.row;
        }
        return false;
    }
    

    Also, since you are using final fields, you can consider pre-calculating/setting your hashCode() and toString() values first.

edit

Pre-calculating/setting what are invariant results first can lead to better performance, since you avoid repetition. What I'm suggesting is:

public final class GoPoint {

    private final int row;
    private final int col;
    private final int hashCode;
    private final String toString;

    public GoPoint(int row, int col) {
        this.row = row;
        this.col = col;
        this.hashCode = calculateHashCode();
        this.toString = "GoPoint [row=" + row + ", col=" + col + "]";
    }

    private int calculateHashCode() {
        final int prime = 31;
        return prime * (prime * col) + row;
    }

    @Override
    public int hashCode() {
        return hashCode;
    }

    @Override
    public String toString() {
        return toString;
    }
}

Another observation:

I now use (as advised) HashMap<GoPoint, StoneColor>...

That doesn't mean you have to declare them as HashMap. :) Declaration by interface (Map<K, V> map = new HashMap<>();) is recommended as it lets you abstract away implementation details from users of the field - they only need to know they are interfacing with a Map instance, and you are free to replace with different implementations down the road as you deem fit.

\$\endgroup\$
  • \$\begingroup\$ "consider pre-calculating..." How and why? \$\endgroup\$ – David Mašek Jun 24 '15 at 18:08
  • \$\begingroup\$ @DavidMašek see updated answer. \$\endgroup\$ – h.j.k. Jun 24 '15 at 23:43
3
\$\begingroup\$
  • I don't think that isDead deserves such comment. It is a fairly standard BFS for a stone with liberty. Actually I'd implement it via a generic BFS.

  • Detecting ko doesn't require the whole previous position. It only may occur if

    • a previous move at a,b resulted in a capture of exactly one stone at x,y and
    • current move is played at x,y and
    • it results in a capture of exactly one stone at a,b.
  • A true score counting amounts to AI. Otherwise you may try to force the players to play boring moves and trust them to agree that only live and seki groups remain.

\$\endgroup\$
  • 1
    \$\begingroup\$ If I can trust them then they can compute it themselves :) (but trusting users is bad IMHO). isDead has that comment because it took me ages to make it work. I wrote that mainly for myself so I won't forget next time I look at it. "...I'd implement it via a generic BFS." - honestly, I tried and failed. A lot. How would that look? Thanks. \$\endgroup\$ – David Mašek Jun 24 '15 at 18:12
  • \$\begingroup\$ Your notes about ko are only true for basic ko, not for super-ko. \$\endgroup\$ – CodesInChaos Jul 12 '15 at 12:24
  • \$\begingroup\$ @CodesInChaos If you mean sensai ko and ethernal ko situations, I am accustomed to the rules in which they are legal (and do result in a draw). For other rule sets, you are right. \$\endgroup\$ – vnp Jul 12 '15 at 22:44
2
\$\begingroup\$

GoPoint

I agree with all the advice by vnp, but there's a simpler way:

  • Make the constructor private
  • Precreate and store all needed points in a private static array
  • Add a public static method like get(int row, int col)

This assures that there will never be two equal points, so you can stick with the inherited equals and hashCode. If your maximum board size isn't fixed, you'll need to resize the array.

IMHO it's better than defining equals and hashCode, but not by much.

GameState

Instead of

private HashMap<GoPoint, StoneColor> previousBlackPosition;

...

previousBlackPosition = new HashMap<>();

write simply

private Map<GoPoint, StoneColor> previousBlackPosition = new HashMap<>();

Instead of previousBlackPosition and previousWhitePosition consider using previousPosition and prePreviousPosition (this would need a better name). AFAIK you don't really care whose turn it is, ko occurs when the pre-previous position repeats).

As you're copying the whole map on every more, you can consider making it immutable. I mean a new immutable class BoardState containing the map and implementing part of the functionality, e.g. a method like

BoardState playAt(int row, int col) {
    ...
}

returning null if the move is forbidden. The big disadvantage of immutability is the need to make copies, but this doesn't apply here, as you'd save copying when storing the history.

System.out.println("true");

Do yourself a favor and use something easier to find. One day you'll see your program true from time to time...

private Set<GoPoint> getNeighbors(GoPoint gp) ...

You could add a method to GoPoint like

Set<GoPoint> getNeighbors(int size) ...

returning a precomputed unmodifiable or immutable set.

Summary

I didn't look at it carefully and I ignored GameBoard, but it looks much better than it did. Actually, it looks fine and all my above comments are either just alternatives or some really minor issues.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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