package org.erikaredmark.monkeyshines.menu; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; import javax.swing.JCheckBoxMenuItem; import javax.swing.JFrame; import javax.swing.JMenu; import javax.swing.JMenuBar; import javax.swing.JMenuItem; import org.erikaredmark.monkeyshines.GameSoundEffect; import org.erikaredmark.monkeyshines.HighScores; import org.erikaredmark.monkeyshines.KeyboardInput; import org.erikaredmark.monkeyshines.World; import org.erikaredmark.monkeyshines.global.KeySettings; import org.erikaredmark.monkeyshines.global.MonkeyShinesPreferences; import org.erikaredmark.monkeyshines.global.PreferencePersistException; import org.erikaredmark.monkeyshines.global.SpecialSettings; import org.erikaredmark.monkeyshines.global.VideoSettings; import org.erikaredmark.monkeyshines.resource.SoundManager; import org.erikaredmark.monkeyshines.util.GameEndCallback; import org.erikaredmark.monkeyshines.menu.SelectAWorld.WorldSelectionCallback; /** * * The primary entry point for the actual game. The main window is a fixed size window that contains, one at a time, * either the game window, main menu, splash screen, or other such 'screens' as needed. This is only used in the main * game. It is NOT a dialog launcher. It is effectively a manager to decide which type of screen (game, menu, etc) should * be shown at any one time. * * @author Erika Redmark * */ public final class MainWindow extends JFrame { private static final long serialVersionUID = 1L; private static final String CLASS_NAME = "org.erikaredmark.monkeyshines.menu.MainWindow"; private static final Logger LOGGER = Logger.getLogger(CLASS_NAME); // The currently running game. May be null if no game is running or if game is running in fullscreen. private GamePanel runningGameWindowed; // The play game panel. This is always initialised as part of the playgame and leaves when that // state is transitioned away. this is shown when the play game button is pressed private SelectAWorld selectWorldPanel; // Carries the selected world state from the select world panel to the start game state transitions. // Is nulled out after use. This isn't exactly happy code, but the alternative is add an optional // parameter to all the state transition methods. private World tempWorld; // Main menu displayed. May be null if the main menu is no longer displayed private MainMenuWindow menu; // High scores displayed. May be null if the high scores are not displayed private ViewHighScores highScores; private GameState state = GameState.NONE; // current key setup is stored so it can be removed as an observer from the main window during state // transitions private KeyboardInput currentKeyListener; // Main menu Bar private JMenuBar mainMenuBar = new JMenuBar(); // Menu: Options private JMenu options = new JMenu("Options"); private final JMenuItem changeFullscreen = new JCheckBoxMenuItem("Fullscreen", null, VideoSettings.isFullscreen() ); private final JMenuItem playtestMode = new JCheckBoxMenuItem("Playtesting", null, SpecialSettings.isThunderbird() ); // Called when 'play game' is pressed in main menu. Transition to the 'choose a world' screen. private final Runnable playGameCallback = new Runnable() { @Override public void run() { setGameState(GameState.CHOOSE_WORLD); } }; private final Runnable highScoresCallback = new Runnable() { @Override public void run() { setGameState(GameState.HIGH_SCORES); } }; public MainWindow() { setTitle("Monkey Shines"); setDefaultCloseOperation(EXIT_ON_CLOSE); setResizable(false); // Do not set size here: It would include title bar. JPanel is added and packed // during state transition to size window correctly. // Set the menu bar. Some functions are accessible from here in lue of buttons (this may change) changeFullscreen.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent arg0) { VideoSettings.setFullscreen(changeFullscreen.isSelected() ); try { VideoSettings.persist(); } catch (PreferencePersistException e) { LOGGER.log(Level.WARNING, CLASS_NAME + ": cannot persist preferences: " + e.getMessage(), e); } } }); options.add(changeFullscreen); playtestMode.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent arg0) { SpecialSettings.setThunderbird(playtestMode.isSelected() ); try { SpecialSettings.persist(); } catch (PreferencePersistException e) { LOGGER.log(Level.WARNING, CLASS_NAME + ": cannot persist preferences: " + e.getMessage(), e); } } }); options.add(playtestMode); mainMenuBar.add(options); setJMenuBar(mainMenuBar); // Initial state is menu. // This must be done after all contents are added to window to size correctly setGameState(GameState.MENU); // Now we can set relative to, since window is already sized properly. setLocationRelativeTo(null); setVisible(true); setAlwaysOnTop(false); } /** * * Sets the game state to either playing in windowed or playing in fullscreen, based on settings. * * @return * {@code true} if state change is successful, {@code false} if otherwise * */ private boolean playGame() { boolean gameStarted = false; if (VideoSettings.isFullscreen() ) { // Try to run fullscreen, but fallback to windowed if unable to if (!(setGameState(GameState.PLAYING_FULLSCREEN) ) ) { gameStarted = setGameState(GameState.PLAYING_WINDOWED); } else { // Else if setting the game state to playing fullscreen succeeded. gameStarted = true; } } else { gameStarted = setGameState(GameState.PLAYING_WINDOWED); } if (gameStarted) { // Gave the world object to the game, no need to keep ref here anymore tempWorld = null; } return gameStarted; } // Sets the new game state and calls transition methods to modify the proper // state information for this object // returns whether the state change was successful. private boolean setGameState(final GameState state) { // This method will actually modify the state variable stored in this object automatically. return state.transitionTo(this); } // Called both ending a standard and a fullscreen game. private final GameEndCallback gameEndCallback = new GameEndCallback() { @Override public void gameOverFail(World w) { setGameState(GameState.MENU); } @Override public void gameOverEscape(World w) { setGameState(GameState.MENU); } @Override public void gameOverWin(World w) { checkHighScore(w); setGameState(GameState.HIGH_SCORES); } }; private enum GameState { // Note: always change state at END of method. Perform any possible actions that could prevent // state change before actually modifying any state variables. // In Transition To, ALWAYS: // 1) Transition FROM the current state after verifying state change is allowed // 2) Transition TO this state after setting up. // No extra-linguistic restrictions for transitionFrom CHOOSE_WORLD { @Override public boolean transitionTo(final MainWindow mainWindow) { mainWindow.state.transitionFrom(mainWindow); mainWindow.selectWorldPanel = new SelectAWorld(new WorldSelectionCallback() { @Override public void worldSelected(final World world) { // Consider this a state transition. Whilst appearing in transitionTo, remember // this isn't actually called until after the transition since that must occur // before the user can do anything. mainWindow.tempWorld = world; if (!(mainWindow.playGame() ) ) { // This really shouldn't happen. LOGGER.warning("Expected to start game but could not: preconditions for state transition not satisfied"); } } }); mainWindow.add(mainWindow.selectWorldPanel); mainWindow.selectWorldPanel.setVisible(true); mainWindow.pack(); // Required because otherwise the main menu will bleed through. mainWindow.repaint(); mainWindow.state = this; return true; } @Override protected void transitionFrom(MainWindow mainWindow) { mainWindow.remove(mainWindow.selectWorldPanel); mainWindow.selectWorldPanel.setFocusable(false); // nothing to dispose, just null the reference for garbage collection. mainWindow.selectWorldPanel = null; } }, // Before changing to this state, tempWorld must be set. PLAYING_WINDOWED { @Override public boolean transitionTo(final MainWindow mainWindow) { if (mainWindow.tempWorld == null) return false; mainWindow.state.transitionFrom(mainWindow); mainWindow.currentKeyListener = new KeyboardInput(); mainWindow.runningGameWindowed = GamePanel.newGamePanel(mainWindow.currentKeyListener, KeySettings.getBindings(), mainWindow.gameEndCallback, mainWindow.tempWorld); // Ensure that keyboard events get focus for the listener mainWindow.requestFocusInWindow(); // Must add to both. mainWindow.addKeyListener(mainWindow.currentKeyListener); mainWindow.add(mainWindow.runningGameWindowed); mainWindow.pack(); mainWindow.state = this; return true; } @Override protected void transitionFrom(MainWindow mainWindow) { if (mainWindow.runningGameWindowed != null) { mainWindow.remove(mainWindow.runningGameWindowed); mainWindow.runningGameWindowed.dispose(); // Nulling reference is important; running game state should be GC'ed as it will no longer be // transitioned back to. mainWindow.runningGameWindowed = null; assert mainWindow.currentKeyListener != null : "Keyboard based game played without a keyboard listener?"; mainWindow.removeKeyListener(mainWindow.currentKeyListener); } } }, PLAYING_FULLSCREEN { @Override public boolean transitionTo(MainWindow mainWindow) { if (mainWindow.tempWorld == null) return false; GameFullscreenWindow fullscreen = new GameFullscreenWindow(new KeyboardInput(), KeySettings.getBindings(), mainWindow.gameEndCallback, mainWindow.tempWorld); if (fullscreen.start() ) { // Fullscreen should only have one active window mainWindow.state.transitionFrom(mainWindow); mainWindow.setVisible(false); mainWindow.setIgnoreRepaint(true); // Ensure that keyboard events get focus for the listener mainWindow.requestFocusInWindow(); mainWindow.state = this; return true; } else { return false; } } @Override protected void transitionFrom(MainWindow mainWindow) { // bring back window mainWindow.setVisible(true); mainWindow.setIgnoreRepaint(false); } }, MENU { @Override public boolean transitionTo(MainWindow mainWindow) { mainWindow.state.transitionFrom(mainWindow); mainWindow.menu = new MainMenuWindow(mainWindow.playGameCallback, mainWindow.highScoresCallback); mainWindow.add(mainWindow.menu); mainWindow.pack(); mainWindow.state = this; return true; } @Override protected void transitionFrom(MainWindow mainWindow) { if (mainWindow.menu != null) { mainWindow.remove(mainWindow.menu); mainWindow.menu = null; } } }, HIGH_SCORES { @Override public boolean transitionTo(final MainWindow mainWindow) { mainWindow.state.transitionFrom(mainWindow); mainWindow.highScores = new ViewHighScores( HighScores.fromFile(MonkeyShinesPreferences.getHighScoresPath() ), new ViewHighScores.BackButtonCallback() { @Override public void backButtonPressed() { mainWindow.setGameState(GameState.MENU); } }); mainWindow.add(mainWindow.highScores); mainWindow.pack(); mainWindow.highScores.setVisible(true); mainWindow.repaint(); mainWindow.state = this; return true; } @Override protected void transitionFrom(MainWindow mainWindow) { if (mainWindow.highScores != null) { mainWindow.remove(mainWindow.highScores); mainWindow.highScores = null; } } }, // Represents no state, so null checks aren't required on state object NONE { @Override public boolean transitionTo(MainWindow mainWindow) { return true; } @Override protected void transitionFrom(MainWindow mainWindow) { } }; /** * * Defines the transition logic for the state. Transitioning to a state will automatically * cleanup the current state and show the new state. * <p/> * A state change can fail. If so, then {@code transitionFrom} was never called, no state * variables were modified. * * @param mainWindow * a 'this' parameter effectively to the main window, since enums have static visibility * * @return * {@code true} if state changed, {@code false} if otherwise. * */ public abstract boolean transitionTo(final MainWindow mainWindow); /** * * Called automatically by {@code transitionTo} for whatever the current state was before actually * performing the transition. This is intended for 'clean-up', such as removing swing objects no * longer appropriate for the new state. * <p/> * Transitioning away can never fail. If it otherwise should, it should be detected in transitionTo. * * @param mainWindow * a 'this' parameter effectively to the main window, since enums have static visibility * */ protected abstract void transitionFrom(final MainWindow mainWindow); } /** * * Determines if the level score is sufficient enough for the high scores (read from a file when this method * is called), and if so fires up the dialog and prompts the user to enter their name. No further state change * continues until they do so making this a blocking call. * <p/> * Should only be called when the level is completed normally and not via escape or death. It is an error to call * this function whilst the world is still in play. * <p/> * A successful high score from a call to this method readies this object to play the applause sound on the next * state change to the high scores part of the window by saving the sound manager. The reference will be removed * once played. * * @param w * the world that was completed. * */ private void checkHighScore(World w) { if (!(w.isWorldFinished() ) ) { throw new IllegalStateException("Cannot tally high scores for an unfinished world"); } final int score = w.getStatistics().getTotalScore(); HighScores highScores = HighScores.fromFile(MonkeyShinesPreferences.getHighScoresPath() ); if (highScores.isScoreHigh(score) ) { SoundManager snd = w.getResource().getSoundManager(); snd.playOnce(GameSoundEffect.YES); String playerName = EnterHighScoreDialog.launch(); highScores.addScore(playerName, score); highScores.persistScores(MonkeyShinesPreferences.getHighScoresPath() ); // In preparation for high scores movement snd.playOnceDelayed(GameSoundEffect.APPLAUSE, 1, TimeUnit.SECONDS); } } }