package org.erikaredmark.monkeyshines; import java.awt.Graphics2D; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.KeyEvent; import javax.swing.Timer; import org.erikaredmark.monkeyshines.resource.WorldResource; import org.erikaredmark.monkeyshines.util.GameEndCallback; import com.google.common.base.Function; /** * * Separates how the game will be drawn to the screen vs the game 'world', as in the flow of time * in a current game, where things are, some drawing information, etc. However, it is up to a dedicated * graphical class to decide how to draw this world onto the screen, and what transformations to do. * This STILL contains graphical components, but it does not actually DRAW them onto a graphics * context. * <p/> * The game logic runs at a speed defined in {@code GameConstants.GAME_SPEED}. The speed in which * the world is 'painted' onto some graphics context should either be equal to or faster than * this speed (most likely equal for passive rendering, and faster for active) for the best * user experience. * <p/> * Practically speaking, this allows both a windowed mode and a fullscreen mode. * * @author Erika Redmark * */ public final class GameWorldLogic { // The primary flow of time private final Timer gameTimer; // Started when the last key is collected, stopped when the 'bonus score' hits zero. private final Timer bonusTimer; // Started when the game is frozen, stopped when the game is unfrozen. Acts as a second flow of time when // the game is frozen, allowing rendering, animating, pause menus, or other manner of stuff to function // whilst the game itself is no longer technically running. Unlike other timers, this timer may be null // and is always constructed upon starting with a new callback. private Timer freezeTimer; // The player and the world private Bonzo bonzo; private World currentWorld; // The splash screen. When true, the splash screen is displayed. Automatically // set to false after a certain amount of running time. private boolean splash; private int splashCounter; /* -------------------- Digits ---------------------- */ /* Numerical values displayed in the UI are broken up * into digits so that the class can easily map a digit * to the image of the digit. */ public static final int SCORE_NUM_DIGITS = 7; // Digits are updated when score is updated. Digits are always drawn from this // array to avoid digit extraction algorithms every frame // Default value 0 is guaranteed by language. Bonzo's score always starts at 0 private final int digits[] = new int[SCORE_NUM_DIGITS]; // Bonus digits public static int BONUS_NUM_DIGITS = 4; // Each new game starts with 10000 countdown, represented by 9999 private int countdownDigits[] = new int[] {9, 9, 9, 9}; /** * * Constructs the living game world. Gigantic constructor but admittedly it * is the parent object for running the world. Most parameters are callbacks * to the UI for critical actions. * <p/> * This world will be in 'stasis', as in not running, when created. {@code start} * must be called. * * @param keys * a keybord input device to allow the world to respond to the player * * @param keyBindings * a binding that specifies which keys are to perform what actions * * @param world * the world that needs to be brought to life and run * * @param endGameCallback * a callback for when the game ends * * @param gameTickCallback * a callback for each tick of the game. This indicates that whatever buffer is holding * the 'image' of the game will have to be updated because it no longer reflects the * game state * * @param lifeLostCallback * a callback when bonzo loses a life but it isn't game over. Note that bonzo's life counter * has already decremented, and at the point of this call, bonzo has already 'restarted' on * the screen and all respawn rules have been applied. This is mainly for graphics renders * to do something special graphically to draw attention to his location. * * @param playtestMode * {@code true} to enter playtesting, infinite lives mode, {@code false} if otherwise * */ public GameWorldLogic(final KeyboardInput keys, final KeyBindings keyBindings, final World world, final GameEndCallback gameEndCallback, final Runnable gameTickCallback, final Function<Bonzo, Void> lifeLostCallback, final boolean playtestMode) { assert keys != null; this.currentWorld = world; bonzo = new Bonzo(currentWorld, // DEBUG: Will eventually be based on difficulty playtestMode ? Bonzo.INFINITE_LIVES : 4, new Runnable() { @Override public void run() { scoreUpdate(); } }, new GameEndCallback() { @Override public void gameOverWin(World sw) { endGame_internal(); gameEndCallback.gameOverWin(world); } @Override public void gameOverFail(World w) { endGame_internal(); gameEndCallback.gameOverFail(world); } @Override public void gameOverEscape(World w) { endGame_internal(); gameEndCallback.gameOverEscape(world); } }, new Function<Bonzo, Void>() { @Override public Void apply(Bonzo bonzo) { currentWorld.restartBonzo(bonzo); // this pointer escape, but function will not be called until gameplay proper lifeLostCallback.apply(bonzo); return null; } }); currentWorld.setAllRedKeysCollectedCallback( new Runnable() { @Override public void run() { redKeysCollected(); } }); gameTimer = new Timer(GameConstants.GAME_SPEED, new ActionListener() { /** * Polls the keyboard for valid operations the player may make on Bonzo. During gameplay, * the only allowed operations are moving left/right or jumping. * This is the method called every tick to run the game logic. This is effectively the * 'entry point' to the main game loop. */ public void actionPerformed(ActionEvent e) { // If splash screen is showing, the only thing we run is the game tick callback, for painting. // When the client calls paintTo, it will decrement the splash tick and eventually remove the // screen. We don't want stuff happening during splash. if (!(splash) ) { // Poll Keyboard keys.poll(); if (keys.keyDown(keyBindings.left) ) { bonzo.move(-1); } if (keys.keyDown(keyBindings.right) ) { bonzo.move(1); } if (keys.keyDown(keyBindings.jump) ) { bonzo.jump(4); } // The only hardcoded key: Esc is a game over if (keys.keyDown(KeyEvent.VK_ESCAPE) ) { endGame_internal(); gameEndCallback.gameOverEscape(world); } // Update the game first before calling what is possibly a paint // routine. currentWorld.update(); bonzo.update(); } gameTickCallback.run(); } }); bonusTimer = new Timer(GameConstants.BONUS_COUNTDOWN_DELAY, new ActionListener() { /** * Counts down the bonus score for this game session. Stops itself at zero. This will * also be stopped when bonzo reaches the exit door or dies. */ public void actionPerformed(ActionEvent e) { // Note: Bonus starts at 10000. We only DISPLAY 9999 and because the // update function never runs until the first update and painting, it will end // up redrawing the value 9990. boolean keepCounting = currentWorld.bonusCountdown(); createDigits(countdownDigits, BONUS_NUM_DIGITS, currentWorld.getCurrentBonus() ); if (!(keepCounting) ) bonusTimer.stop(); } }); } /** * * Sets the splash display to 'splash'. If true, resets the splash counter. * <p/> * Do not set the variable directly or the counter will not be reset. * * @param splash * {@code true} to show splash screen, {@code false} to shut it off * */ private void setSplash(boolean showSplash) { splash = showSplash; splashCounter = showSplash ? GameConstants.SPLASH_TICKS : 0; } /** * * Paints the world to the given graphics context. If the splash screen is being drawn, each call * decrements a tick the splash screen should be visible. * TODO this is temporary. Eventually I want to segregate this even further so elements * aren't responsible for painting themselves, making it possible to support hi-def graphics * or any other interesting transformations. * * @param g * */ public void paintTo(Graphics2D g) { if (!(splash) ) { currentWorld.paint(g); bonzo.paint(g); } else { g.drawImage(getResource().getSplashScreen(), 0, 0, null); --splashCounter; if (splashCounter < 0) setSplash(false); } } /** * * Starts time. Does nothing if time has already started. * Both the running music and the timer will operate on a different thread than what called this method. * * @param showSplash * if {@code true}, the splash screen will be drawn for however many game ticks * equate to 4 seconds. * */ public void start(boolean showSplash) { setSplash(showSplash); gameTimer.start(); this.currentWorld.getResource().getSoundManager().playMusic(); } /** * * Returns {@code true} if the splash screen is being shown. * If so, normally renders should render from 0,0 origin point, and not account for UI. * */ public boolean showingSplash() { return splash; } /** * * Freezes time. Game will not respond to user events and will not update. Does * nothing if time has already been frozen. * <p/> * A second timer is spawned with the same rate as the one stopped, and it will * call the passed callback. This allows special things, like painting or animating, * to be done even when the actual game-clock is stopped. * * @param freezeMusic * {@code true} to stop the music, {@code false} to allow it to continue playing. * * @param tickCallback * a second tick callback function that will fire each tick the game is frozen. * */ public void freeze(boolean freezeMusic, final Runnable tickCallback) { gameTimer.stop(); if (freezeMusic) { this.currentWorld.getResource().getSoundManager().stopPlayingMusic(); } freezeTimer = new Timer(GameConstants.GAME_SPEED, new ActionListener() { @Override public void actionPerformed(ActionEvent e) { tickCallback.run(); } }); freezeTimer.start(); } /** * * Unfreezesm time. Game will respond normally to events and update. Does nothing if the * game is already unfrozen. if the music was stopped, will resume. * */ public void unfreeze() { if (!(gameTimer.isRunning() ) ) { assert freezeTimer != null : "No freeze timer was started during a game freeze!"; freezeTimer.stop(); freezeTimer = null; gameTimer.start(); } this.currentWorld.getResource().getSoundManager().playMusic(); } // Called from callback when bonzos score is updated in game. Sets digit values for // score redraw. private void scoreUpdate() { int rawScore = bonzo.getScore(); createDigits(digits, SCORE_NUM_DIGITS, rawScore); } public World getWorld() { return currentWorld; } // Given a raw value that is the right size to fit each digit into an index of the // array, transforms it into an array of 0-9 integers for drawing algorithms. private static void createDigits(int[] digitArray, int numOfDigits, int rawValue) { // modulations are computed from biggest to smallest singificant digit. // We must store them in the opposite direction to properly handle digits. for (int i = numOfDigits - 1, modular = GameConstants.TEN_POWERS[i + 1], digitIndex = 0; i >= 0; --i, ++digitIndex) { // Note: We need to compute the divisor to normalise to a number between 0-9, which // will end up being the next modular anyway. int divisor = GameConstants.TEN_POWERS[i]; // Small correction to arithmetic during the last digit. A modulus of 1 technically // is not divided again. // if (divisor == 0) divisor = 1; digitArray[digitIndex] = (rawValue % modular) / divisor; // readies the next digit extraction for next loop. modular = divisor; } } // Called when bonzo has collected all the red keys. private void redKeysCollected() { // Start the countdown timer bonusTimer.start(); } // Common code for all types of game endings private void endGame_internal() { bonusTimer.stop(); currentWorld.getResource().getSoundManager().stopPlayingMusic(); currentWorld.worldFinished(bonzo); } /** * * Returns the resource data for the world that is currently running. * * @return * */ public WorldResource getResource() { return currentWorld.getResource(); } /** * * Returns a numerical representation of bonzos health, required * to draw the health bar. * * @return * */ public int getBonzoHealth() { return bonzo.getHealth(); } /** * * Returns the array of digits, from most to least significant, that represent * the current player score. The returned array IS the backing array and should not * be modified by the caller. * * @return * */ public int[] getScoreDigits() { return digits; } /** * * Returns the array of digits, from most to least significant, that represent * the countdown for the bonus, which is started when the last red key is collected. * The returned array IS the backing array and should not be modified by the caller. * * @return * */ public int[] getBonusDigits() { return countdownDigits; } /** * * Returns the number of lives bonzo currently has as a single digit, which * always represents the right number of lives as bonzo may only have up to * 9 lives. * <p/> * This may return {@code -2}, which indicates an infinite number of lives and * should be given special handling. * * @return * */ public int getLifeDigit() { return bonzo.getLives(); } /** * * Determines if a powerup is visible in the powerup-indicator on the UI. This does * NOT say if bonzo HAS a powerup, only if it should be drawn or not. Powerups are * still available and not drawn when they are 'fading'. This is for drawing purposes * only. * * @return * {@code true} if a powerup should be drawn, {@code false} if otherwise * */ public boolean isPowerupVisible() { return bonzo.powerupUIVisible(); } /** * * Returns the current powerup bonzo is holding. This should only be called if * {@code isPowerupVisible} returns {@code true}. * * @return * */ public Powerup getCurrentPowerup() { return bonzo.getCurrentPowerup(); } /** * * Disposes of graphics and sounds for this running game. This must be called * exactly ONCE before the last reference of this object is about to go out of scope. * Failure to call will result in memory leaks. * */ public void dispose() { currentWorld.getResource().dispose(); gameTimer.stop(); bonusTimer.stop(); } public Bonzo getBonzo() { return bonzo; } }