First version: Go (board game) in Java
What's new:
- I now use (as advised)
HashMap<GoPoint, StoneColor>
whereGoPoint
is my "coordinate" class andStoneColor
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;
}
}
git clone [url]
. – QPaysTaxes Jun 24 at 14:58