package org.mafagafogigante.dungeon.gui;
import org.mafagafogigante.dungeon.commands.CommandHistory;
import org.mafagafogigante.dungeon.commands.IssuedCommand;
import org.mafagafogigante.dungeon.game.Game;
import org.mafagafogigante.dungeon.game.GameState;
import org.mafagafogigante.dungeon.game.Writable;
import org.mafagafogigante.dungeon.io.Loader;
import org.mafagafogigante.dungeon.logging.DungeonLogger;
import org.mafagafogigante.dungeon.util.StopWatch;
import org.jetbrains.annotations.NotNull;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.FontFormatException;
import java.awt.FontMetrics;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Toolkit;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.InputEvent;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import javax.swing.AbstractAction;
import javax.swing.BorderFactory;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import javax.swing.JTextPane;
import javax.swing.KeyStroke;
import javax.swing.ScrollPaneConstants;
import javax.swing.SwingUtilities;
import javax.swing.SwingWorker;
import javax.swing.WindowConstants;
public class GameWindow extends JFrame {
/**
* Returns how many text rows are shown in the Window.
*/
private static final int ROWS = 30;
private static final int COLUMNS = 100;
private static final int FONT_SIZE = 15;
private static final Font FONT = getMonospacedFont();
private static final String WINDOW_TITLE = "Dungeon";
/**
* The border, in pixels.
*/
private static final int MARGIN = 5;
private transient SwappingStyledDocument document;
private JTextField textField;
private JTextPane textPane;
private volatile boolean acceptingNextCommand;
/**
* Constructs a new GameWindow.
*/
public GameWindow() {
initComponents();
document = new SwappingStyledDocument(textPane);
setVisible(true);
}
public static int getRows() {
return ROWS;
}
public static int getColumns() {
return COLUMNS;
}
/**
* Returns the monospaced font used by the game interface.
*/
private static Font getMonospacedFont() {
Font font = new Font(Font.MONOSPACED, Font.PLAIN, FONT_SIZE);
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
if (contextClassLoader == null) {
DungeonLogger.warning("getContextClassLoader() returned null. Not attempting to get custom font.");
} else {
try (InputStream fontStream = contextClassLoader.getResourceAsStream("DroidSansMono.ttf")) {
font = Font.createFont(Font.TRUETYPE_FONT, fontStream).deriveFont(Font.PLAIN, FONT_SIZE);
} catch (FontFormatException bad) {
DungeonLogger.warning("threw FontFormatException during font creation.");
} catch (IOException bad) {
DungeonLogger.warning("threw IOException during font creation.");
}
}
return font;
}
private static void logExecutionExceptionAndExit(Throwable fatal) {
DungeonLogger.logSevere(fatal);
System.exit(1);
}
private Object readResolve() {
document = new SwappingStyledDocument(textPane);
return this;
}
private void initComponents() {
JPanel panel = new JPanel(new GridBagLayout());
panel.setBackground(SharedConstants.MARGIN_COLOR);
textPane = new JTextPane();
textPane.setEditable(false);
textPane.setBackground(SharedConstants.INSIDE_COLOR);
textPane.setFont(FONT);
JScrollPane scrollPane = new JScrollPane();
scrollPane.setViewportView(textPane);
scrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS);
scrollPane.setBorder(BorderFactory.createEmptyBorder());
scrollPane.getVerticalScrollBar().setBackground(SharedConstants.INSIDE_COLOR);
scrollPane.getVerticalScrollBar().setUI(new DungeonScrollBarUi());
textField = new JTextField();
textField.setBackground(SharedConstants.INSIDE_COLOR);
textField.setForeground(Color.LIGHT_GRAY);
textField.setCaretColor(Color.WHITE);
textField.setFont(FONT);
textField.setFocusTraversalKeysEnabled(false);
textField.setBorder(BorderFactory.createEmptyBorder());
textField.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent actionEvent) {
textFieldActionPerformed();
}
});
textField.addKeyListener(new KeyAdapter() {
@Override
public void keyPressed(KeyEvent event) {
textFieldKeyPressed(event);
}
});
GridBagConstraints constants = new GridBagConstraints();
constants.insets = new Insets(MARGIN, MARGIN, MARGIN, MARGIN);
panel.add(scrollPane, constants);
constants.gridy = 1;
constants.fill = GridBagConstraints.HORIZONTAL;
constants.insets = new Insets(0, MARGIN, MARGIN, MARGIN);
panel.add(textField, constants);
setGameWindowTitle(WINDOW_TITLE);
setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
addWindowListener(new ClosingListener());
textField.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_S, InputEvent.CTRL_DOWN_MASK), "SAVE");
textField.getActionMap().put("SAVE", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent event) {
if (acceptingNextCommand) {
clearTextPane();
Loader.saveGame(Game.getGameState());
}
}
});
textField.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_U, InputEvent.CTRL_DOWN_MASK), "DELETE_BEFORE");
textField.getActionMap().put("DELETE_BEFORE", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent event) {
if (acceptingNextCommand) {
int caretPosition = textField.getCaretPosition();
textField.setText(textField.getText().substring(caretPosition));
textField.setCaretPosition(0);
}
}
});
textField.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_K, InputEvent.CTRL_DOWN_MASK), "DELETE_AFTER");
textField.getActionMap().put("DELETE_AFTER", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent event) {
if (acceptingNextCommand) {
int caretPosition = textField.getCaretPosition();
textField.setText(textField.getText().substring(0, caretPosition));
}
}
});
textField.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_W, InputEvent.CTRL_DOWN_MASK), "DELETE_WORD_BEFORE");
textField.getActionMap().put("DELETE_WORD_BEFORE", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent event) {
if (acceptingNextCommand) {
String text = textField.getText();
boolean gotToken = false;
int caretPosition = textField.getCaretPosition();
for (int i = caretPosition - 1; i >= 0; i--) {
if (Character.isWhitespace(text.charAt(i))) {
if (gotToken) {
int endIndex = i + 1;
int offset = caretPosition - endIndex;
textField.setText(text.substring(0, endIndex) + text.substring(caretPosition));
textField.setCaretPosition(caretPosition - offset);
return;
}
} else {
if (!gotToken) {
gotToken = true;
}
}
}
textField.setText(text.substring(caretPosition));
textField.setCaretPosition(0);
}
}
});
add(panel);
setResizable(false);
resize();
}
private void setGameWindowTitle(String title) {
setTitle(title);
// Set the ClassName so that Gnome presents the window title correctly.
// See https://github.com/mafagafogigante/dungeon/issues/307 for more information.
Toolkit toolkit = Toolkit.getDefaultToolkit();
try {
Field awtAppClassNameField = toolkit.getClass().getDeclaredField("awtAppClassName");
awtAppClassNameField.setAccessible(true);
awtAppClassNameField.set(toolkit, title);
} catch (NoSuchFieldException ignored) {
// Not a problem.
} catch (IllegalAccessException logged) {
DungeonLogger.logSevere(logged);
}
}
/**
* Resizes and centers the frame.
*/
private void resize() {
textPane.setPreferredSize(calculateTextPaneSize());
pack();
setLocationRelativeTo(null);
}
/**
* Evaluates the preferred size for the TextPane.
*
* @return a Dimension with the preferred TextPane dimensions.
*/
private Dimension calculateTextPaneSize() {
FontMetrics fontMetrics = getFontMetrics(FONT);
int width = fontMetrics.charWidth(' ') * (COLUMNS + 1); // Add a padding column.
int height = fontMetrics.getHeight() * ROWS;
return new Dimension(width, height);
}
/**
* The method that gets called when the player presses ENTER.
*/
private void textFieldActionPerformed() {
if (acceptingNextCommand) {
final String text = getTrimmedTextFieldText();
if (!text.isEmpty()) {
clearTextField();
// Visually accepted the command here. Start tracking time from here onwards.
final StopWatch stopWatch = new StopWatch();
acceptingNextCommand = false;
SwingWorker<Void, Void> inputRenderer = new SwingWorker<Void, Void>() {
@Override
protected Void doInBackground() {
if (IssuedCommand.isValidSource(text)) {
DungeonLogger.logCommandRenderingReport(text, "started doInBackGround", stopWatch);
try {
Game.renderTurn(new IssuedCommand(text), stopWatch);
} catch (Throwable throwable) {
logExecutionExceptionAndExit(throwable);
}
DungeonLogger.logCommandRenderingReport(text, "finished doInBackGround", stopWatch);
} else {
DungeonLogger.warning("Input is not a valid command source.");
}
acceptingNextCommand = true;
return null;
}
};
inputRenderer.execute();
}
}
}
/**
* Handles a key press in the text field. This method checks for a command history access by the keys UP, DOWN, or TAB
* and, if this is the case, processes this query.
*
* @param event the KeyEvent.
*/
private void textFieldKeyPressed(KeyEvent event) {
int keyCode = event.getKeyCode();
if (isUpDownOrTab(keyCode)) { // Check if the event is of interest.
GameState gameState = Game.getGameState();
if (gameState != null) {
CommandHistory commandHistory = gameState.getCommandHistory();
if (keyCode == KeyEvent.VK_UP) {
textField.setText(commandHistory.getCursor().moveUp().getSelectedCommand());
} else if (keyCode == KeyEvent.VK_DOWN) {
textField.setText(commandHistory.getCursor().moveDown().getSelectedCommand());
} else if (keyCode == KeyEvent.VK_TAB) {
// Using the empty String to get the last similar command will always retrieve the last command.
// Therefore, there is no need to check if there is something in the text field.
String lastSimilarCommand = commandHistory.getLastSimilarCommand(getTrimmedTextFieldText());
if (lastSimilarCommand != null) {
textField.setText(lastSimilarCommand);
}
}
}
}
}
private boolean isUpDownOrTab(int keyCode) {
return keyCode == KeyEvent.VK_UP || keyCode == KeyEvent.VK_DOWN || keyCode == KeyEvent.VK_TAB;
}
/**
* Convenience method that returns the text in the text field after trimming it.
*
* @return a trimmed String.
*/
private String getTrimmedTextFieldText() {
String textFieldContent = textField.getText();
if (textFieldContent == null) {
return "";
} else {
return textFieldContent.trim();
}
}
/**
* Schedules the writing of the contents of a Writable with the provided specifications on the Event Dispatch Thread.
* This method can be called on any thread.
*
* @param writable a Writable object
* @param specifications a WritingSpecifications object
*/
public void scheduleWriteToTextPane(@NotNull final Writable writable,
@NotNull final WritingSpecifications specifications) {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
writeToTextPane(writable, specifications);
}
});
}
/**
* Effectively updates the text pane. Should only be invoked on the Event Dispatch Thread.
*
* @param writable a Writable object, not empty
* @param specifications a WritingSpecifications object
*/
private void writeToTextPane(Writable writable, WritingSpecifications specifications) {
// This is the only way to write text to the screen. One should never modify the contents of the document currently
// assigned to the JTextPane directly. It must be done through the SwappingStyledDocument object.
document.write(writable, specifications);
}
/**
* Clears the TextPane by erasing everything in the local Document.
*
* <p>This schedules the operation to be ran on the EDT, so it is safe to invoke this on any thread.
*/
public void clearTextPane() {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
document.clear();
}
});
}
/**
* Schedules a focus request on the text field.
*/
public void requestFocusOnTextField() {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
textField.requestFocusInWindow();
}
});
}
private void clearTextField() {
textField.setText(null);
}
/**
* Signalizes to this window that it should start accepting commands.
*
* <p>This must be done after the first GameState is loaded. Other changes of GameState do not need to be protected
* this way because the SwingWorker toggles the acceptingNextCommand variable to false and just changes it back to
* true after it is finished (and the GameState is loaded).
*/
public void startAcceptingCommands() {
acceptingNextCommand = true;
}
private static class ClosingListener extends WindowAdapter {
@Override
public void windowClosing(WindowEvent event) {
super.windowClosing(event);
Game.exit();
}
}
}