package org.erikaredmark.monkeyshines; import java.awt.Graphics2D; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.logging.Logger; import java.util.Set; import org.erikaredmark.monkeyshines.Conveyer.Rotation; import org.erikaredmark.monkeyshines.Goodie.Type; import org.erikaredmark.monkeyshines.Sprite.SpriteType; import org.erikaredmark.monkeyshines.bounds.Boundable; import org.erikaredmark.monkeyshines.bounds.IPoint2D; import org.erikaredmark.monkeyshines.editor.importlogic.WorldTranslationException; import org.erikaredmark.monkeyshines.editor.importlogic.WorldTranslationException.TranslationFailure; import org.erikaredmark.monkeyshines.resource.WorldResource; import org.erikaredmark.monkeyshines.tiles.ConveyerTile; import org.erikaredmark.monkeyshines.tiles.HazardTile; import org.erikaredmark.monkeyshines.tiles.PlaceholderTile; import org.erikaredmark.monkeyshines.tiles.TileType; import org.erikaredmark.util.collection.RingArray; import com.google.common.collect.HashMultimap; import com.google.common.collect.Multimap; /** * Holds all information about the entire world, including methods and data to perform the actual running of the * game. * Loads up all sprite sheets, sends references of them to the screens who send references to the tiles. * stores all the data for the screens, allowing the screens to draw themselves easily. * * Every World is composed of the following parts, which are stored in memory * * An Array of full-screen backgrounds. Please keep the number of these to a minimum and use the * PPAT patterns implementation to save on memory. * * A Map consisting of all the goodies in the world. The theoritical limit is infinite. Unlike in * the original, the limit of the number of goodies is governed by the memory used. * * The Map of the world screens. The reason this is a hashmap is simple. In the original game, level * ID's, as seen by the editor, were given an integer value. Moving left or right incremented or decremented * by one, moving up or down incremented/decremented by 100. This means that if some worlds have levels that * are too horizontal, there can be a potential for collision. The new implementation is based on the same * principle for speed, and just increases the values so the collision will happen at a point that the world * size is unlikely to get to; already it is huge. * * The integer value of the current screen. * */ public class World { private static final String CLASS_NAME = "org.erikaredmark.monkeyshines.World"; private static final Logger LOGGER = Logger.getLogger(CLASS_NAME); /** * * generates 'amt' conveyer SETS. Each set is two conveyers; one clockwise and one anti-clockwise. Newly generated conveyers * are added to the end of the list. * <p/> * Because the list is mutated, it must NOT be an immutable type. * */ public static void generateConveyers(List<Conveyer> conveyers, int amt) { // Two entries in the list per conveyer id. Rotation determines which one. int startId = conveyers.size() / 2; for (int i = 0; i < amt; ++i) { conveyers.add(new Conveyer(startId + i, Rotation.CLOCKWISE) ); conveyers.add(new Conveyer(startId + i, Rotation.ANTI_CLOCKWISE) ); } } /** * * Creates a new world skinned with the current resource. The new world starts with one screen (1000) which * contains no tiles, no goodies, sprites, or anything, background id 0, and bonzos starting position is in the upper right corner. * * @param name * the name of the world * * @return * a new world * */ public static World newWorld(final String name, final WorldResource rsrc) { LevelScreen initialScreen = LevelScreen.newScreen(1000, rsrc); Map<Integer, LevelScreen> screens = new HashMap<>(); screens.put(1000, initialScreen); // Generate # of conveyers proportional to graphics context size List<Conveyer> conveyers = new ArrayList<>(rsrc.getConveyerCount() * 2); generateConveyers(conveyers, rsrc.getConveyerCount() ); // Generate # of hazards proportional to graphics context size. List<Hazard> hazards = Hazard.initialise(0, rsrc.getHazardCount(), rsrc); World newWorld = new World(name, new HashMap<WorldCoordinate, Goodie>(), screens, hazards, conveyers, GameConstants.DEFAULT_BONUS_SCREEN, rsrc); newWorld.resetAllScreens(); return newWorld; } /** * * Returns the current resource skinning this worlds graphics, sounds, and music. * * @return * current resource * */ public WorldResource getResource() { return this.rsrc; } /** * * Explicitly sets the modifiable data values of the world. Does not set the graphics resources, which must be done through a call to * {@code skin} * <p/> * This is intended for static factories and the decoding system for .world files ONLY. No defensive copies of parameters are made, * so it is the responsibility of the client to ensure there is no data sharing. * */ public World(final String worldName, final Map<WorldCoordinate, Goodie> goodiesInWorld, final Map<Integer, LevelScreen> worldScreens, final List<Hazard> hazards, final List<Conveyer> conveyers, final int bonusScreen, final WorldResource rsrc) { /* Variable data */ this.worldName = worldName; this.goodiesInWorld = goodiesInWorld; this.worldScreens = worldScreens; this.hazards = hazards; this.conveyers = conveyers; this.bonusScreen = bonusScreen; this.rsrc = rsrc; /* Constant data */ this.currentScreen = 1000; this.bonusCountdown = 10000; /* Default data */ this.author = "Unknown"; this.returnScreen = null; /* Data that can be computed */ // We must pre-construct the mapping of screen to goodie list, for // speed in some reset algorithms, and we must precompute red/blue // key sets. this.goodiesPerScreen = HashMultimap.create(); this.redKeys = new HashSet<Goodie>(); this.blueKeys = new HashSet<Goodie>(); for (Entry<WorldCoordinate, Goodie> entry : goodiesInWorld.entrySet() ) { // Extract just the level id. Assume it can convert to integer, because otherwise would // indicate level corruption anyway. WorldCoordinate coordinate = entry.getKey(); LevelScreen screenForGoodie = worldScreens.get(coordinate.getLevelId() ); Goodie value = entry.getValue(); goodiesPerScreen.put(screenForGoodie.getId(), new GoodieLocationPair(value, coordinate) ); // Now fill in the proper red and blue keys as required if (value.getGoodieType() == Goodie.Type.RED_KEY) this.redKeys.add(value); else if (value.getGoodieType() == Goodie.Type.BLUE_KEY) this.blueKeys.add(value); } // To easily enable bonus and exit doors, we add all such sprites to lists // based on type. for (LevelScreen lvl : worldScreens.values() ) { for (Sprite s : lvl.getSpritesOnScreen() ) { if (s.getType() == SpriteType.EXIT_DOOR) this.exitDoors.add(s); else if (s.getType() == SpriteType.BONUS_DOOR) this.bonusDoors.add(s); } } // Finally, if for some reason bonzo dies on the first screen, we set the initial safe place to be // the starting location, as this is the ONLY point in the entire game that our ring buffer of screens // will be empty. final LevelScreen currentScreen = getCurrentScreen(); currentScreen.setBonzoLastOnGround(currentScreen.getBonzoStartingLocation() ); } /** * * Should be called after construction to reset all screens to default; creating the initial staggering animation * effect. It is up to client to call this as otherwise an overridable method would be called from the constructor, * and in some cases this is not desired (like import logic) * */ public void resetAllScreens() { for (LevelScreen lvl : worldScreens.values() ) { lvl.resetScreen(); } } /** * Returns a LevelScreen in this level pointed to by ID if that screen exists. * @param id the id number of the screen to retrieve. Remember that the first screen in the map will be * ID 1000. The id must resolve to a proper screen or an exception will be thrown. Use {@code #screenIdExists(int)} if unsure * @return */ public LevelScreen getScreenByID(final int id) { LevelScreen s = worldScreens.get(id); if (s == null) throw new IllegalArgumentException("Id " + id + " refers to an invalid screen"); return s; } /** * Determines if a given screen id exists in this world. Attempts to get level screens for invalid ids will result in exceptions. * * @param id * the id of the screen * * @return * {@code true} if the screen exists, {@code false} if otherwise */ public boolean screenIdExists(final int id) { return (worldScreens.get(id) != null); } /** * Take the currentScreen integer and uses it to resolve the actual LevelScreen object * @return a LevelScreen object identified by the integer ID currentScreen. */ public LevelScreen getCurrentScreen() { return getScreenByID(currentScreen); } /** * Gets the currentScreen ID number. Does not return the actual LevelScreen object. * @return The ID number of the current screen. */ public int getCurrentScreenId() { return currentScreen; } /** * Gets the bonus screen id of the current bonus screen * @return id of bonus screen */ public int getBonusScreen() { return bonusScreen; } /** * Changes the location the bonus door on the return screen should take bonzo. * Only to be called from level editor * @param id */ public void setBonusScreen(int id) { this.bonusScreen = id; } public String getAuthor() { return this.author; } public void setAuthor(final String author) { this.author = author; } /** * * Indicates the given key has been collected and removes if from the set. If this was * the last key, also toggles the 'allRedKeysTaken' method. * <p/> * If assertions are enabled, this throws an assertion error if the goodie type is not * a red key or if the set does not contain the key anymore. Program logic should prevent * collections for already taken goodies. * * @param goodie * the key collected * */ public void collectedRedKey(Goodie goodie) { assert goodie.getGoodieType() == Type.RED_KEY : "Cannot collect a red key of " + goodie + " as that isn't a red key"; assert this.redKeys.contains(goodie) : "Red Key " + goodie + " already collected: Logic Error"; this.redKeys.remove(goodie); if (this.redKeys.isEmpty() ) allRedKeysTaken(); } /** * * Same as {@code collectedRedKey} only for blue keys * * @param goodie * the key collected * */ public void collectedBlueKey(Goodie goodie) { assert goodie.getGoodieType() == Type.BLUE_KEY : "Cannot collect a blue key of " + goodie + " as that isn't a blue key"; assert this.blueKeys.contains(goodie) : "Blue Key " + goodie + " already collected: Logic Error"; this.blueKeys.remove(goodie); if (this.blueKeys.isEmpty() ) allBlueKeysTaken(); } public void allRedKeysTaken() { this.rsrc.getSoundManager().playOnce(GameSoundEffect.LAST_RED_KEY); for (Sprite s : exitDoors) { s.setVisible(true); } if (allRedKeysCollectedCallback != null) { allRedKeysCollectedCallback.run(); } } public void allBlueKeysTaken() { this.rsrc.getSoundManager().playOnce(GameSoundEffect.LAST_BLUE_KEY); for (Sprite s : bonusDoors) { s.setVisible(true); } } /** * * Sets the callback function to be called when all the red keys have been collected. This object has * its own game logic handling for all red keys (making exit visible) but it is up to UI to start the * bonus countdown timer. * * @param runnable * the runnable that will run as soon as all the red keys are collected. * */ public void setAllRedKeysCollectedCallback(final Runnable runnable) { this.allRedKeysCollectedCallback = runnable; } /** * * Decrements the bonus for this world by 10. When the bonus hits zero, this * returns false to indicate no more countdowns can be down. Returns true otherwise. * <p/> * Has the sideffect of playing the bonus countdown sound effect. * * @return * {@code true} if the bonus can be decremented again, {@code false} if otherwise * */ public boolean bonusCountdown() { assert bonusCountdown > 9 : "Cannot decrement bonus anymore; timer should have stopped"; bonusCountdown -= 10; rsrc.getSoundManager().playOnce(GameSoundEffect.TICK); return bonusCountdown > 0; } /** * * @return * the current bonus for the world. This is the bonus shown in red numbers that counts down * once all red keys are found. * */ public int getCurrentBonus() { return bonusCountdown; } /** * Looks at the loaded LevelScreen and determines where Bonzo is supposed to restart from. This restart * value is either loaded from the LevelScreen constant (if Bonzo started here) or from the place he * entered the screen from IF his ground location is non null. If he never hit the ground on the * current screen, we use a different algorithm (otherwise he may infinitely respawn over death) * Up to {@code GameConstants.LEVEL_HISTORY} previous levels are checked for a non-null ground * location to safely respawn bonzo. If that fails, bonzo will respawn at the place he entered the * earliest screen in the history from, in the hopes that the level design isn't completely evil. * <p/> * After calling this method, it is advisable to call the 'respawnGrace' method, which pauses the game and points * out where bonzo is, giving a more fair reaction time. {@link GameWorldLogic#respawnGrace()} * * @param theBonzo */ public void restartBonzo(Bonzo theBonzo) { // no matter what, we are resetting this screen. Must do this first as restarting bonzo // is an if-else mess of early returns. resetCurrentScreen(); // If the current screen has valid ground landings, just use the cameFrom location final LevelScreen currentScreen = getCurrentScreen(); ImmutablePoint2D ground = currentScreen.getBonzoLastOnGround(); if (ground != null) { // No need to change screen in world; It's the same one // First, check if where bonzo came from is safe (on the ground). If it is not, such as // bonzo falling onto the screen with a wing, then dying later, we use the ground state, which // we already confirmed is not null // Snap bonzo's come from position, and look TWO tiles below (bonzo takes up 2, so top left + 2 gets bottom) // on both left and right. Lack of solid ground indicates that the ground state, that the come-from state, // should be used. BonzoSaveState bonzoCameFrom = currentScreen.getBonzoCameFrom(); ImmutablePoint2D bonzoCameFromPoint = ImmutablePoint2D.of(bonzoCameFrom.x, bonzoCameFrom.y); int t1x = bonzoCameFrom.x / GameConstants.TILE_SIZE_X; int ty = bonzoCameFrom.y / GameConstants.TILE_SIZE_Y + 2; int t2x = t1x + 1; // Basic sprite check // We look four tiles down, max before fall becomes damaging. if (spriteSafetyCheck(currentScreen, bonzoCameFromPoint) ) { for (int dist = 0; dist < 4; ++dist) { final TileMap map = currentScreen.getMap(); if ( map.getTileXY(t1x, ty + dist).isLandable() || map.getTileXY(t2x, ty + dist).isLandable() // Special case: bonzo JUMPED into this room. Safe to respawn where he came from || ty + dist > GameConstants.LEVEL_ROWS) { theBonzo.restartBonzoOnScreen(currentScreen, bonzoCameFrom); return; } } } // else no early return, not safe to respawn // Entry point into screen not safe. Go to ground. This may make certain // levels easier, but easier is better than infinite death. if (spriteSafetyCheck(currentScreen, ground) ) { theBonzo.restartBonzoOnScreen(currentScreen, BonzoSaveState.fromPoint(ground) ); return; } } // This is ONLY reached if we did not early return, which would only happen if a previous check // failed. // Uh oh! We need to progress backwards through screen history and find a good ground RingArray<LevelScreen> screenHistory = theBonzo.getScreenHistory(); for (LevelScreen s : screenHistory) { ImmutablePoint2D sGround = s.getBonzoLastOnGround(); if (sGround == null) continue; if (!(spriteSafetyCheck(s, sGround) ) ) continue; // Must change world screen as well as bonzos reference changeCurrentScreen(s.getId(), theBonzo); // Valid ground: Restart bonzo and end the method early. theBonzo.restartBonzoOnScreen(s, BonzoSaveState.fromPoint(sGround) ); return; // Note: we do NOT use starting locations defined on levels here, otherwise we may accidentally // backtrack the player if a screen contains multiple paths. } // Reaching the end of the for loop normally signifies no valid ground in ALL // history. That must be one LONG fall; move bonzo to the last screen. final LevelScreen lastResort = screenHistory.back(); changeCurrentScreen(lastResort.getId(), theBonzo); BonzoSaveState lastResortCameFrom = lastResort.getBonzoCameFrom(); if (spriteSafetyCheck(lastResort, ImmutablePoint2D.of(lastResortCameFrom.x, lastResortCameFrom.y ) ) ) { theBonzo.restartBonzoOnScreen(lastResort, lastResort.getBonzoCameFrom() ); } else { // Okay, unconditional respawn on the starting location defined in the level... If there is // a sprite there, that is the level designers fault. We tried our best. ImmutablePoint2D lastResortPoint = lastResort.getBonzoStartingLocationPixels(); theBonzo.restartBonzoOnScreen(lastResort, BonzoSaveState.fromPoint(lastResortPoint) ); } } /** * * Looks at bonzo and the given screen, and decides if that location is unsafe for spawn due to sprites * */ private static boolean spriteSafetyCheck(final LevelScreen screen, final ImmutablePoint2D respawnLocation) { ImmutableRectangle respawnBox = ImmutableRectangle.of(respawnLocation.x(), respawnLocation.y(), 40, 40); // Note: In ALL cases, only Killers and Energy Drainers affect this algorithm for (Sprite nextSprite : screen.getSpritesOnScreen() ) { if (nextSprite.getType() != SpriteType.NORMAL && nextSprite.getType() != SpriteType.HEALTH_DRAIN) continue; ImmutableRectangle spriteBounds = nextSprite.getCurrentBounds(); if (spriteBounds.intersect(respawnBox) != null) { return false; } } // else if for loop terminated normally return true; } /** * * Transfers bonzo to either the bonus room if he is not already in it, or the return room if in the bonus room. * <p/> * In the event that this transfer fails because of an incorrect world design, this may do nothing. * * @param bonzo * * @return * {@code true} if bonzo was transferred, {@code false} if the bonus screen id was set to a non-existant screen * */ public boolean bonusTransfer(Bonzo bonzo) { int transferScreenId; if (returnScreen == null) { // Branch 1: Return screen not yet; this is bonzos first trip and his destination is the bonus screen. This current // screen is his return transferScreenId = bonusScreen; // Only place in entire object this variable should be set from! returnScreen = currentScreen; } else { // Branch 2: Return screen is already set. If bonzo is entering a bonus door ON the return screen, then he is sent // to the bonus room. Otherwise, he is sent to the return screen. // Note that the bonus room need not be the same room as the bonus door that leads off of it. transferScreenId = currentScreen == returnScreen ? bonusScreen : returnScreen; } // Must change world screen as well as bonzos reference // Unlike respawning, this DOES count as screen history! if (!(screenIdExists(transferScreenId) ) ) { // Indicate that the level designer must fix this by logging the issue. LOGGER.severe("Bonus screen " + transferScreenId + " does not exist. Cannot teleport Bonzo: The bonus screen was NOT properly set in the level editor!"); return false; } LevelScreen transferScreen = getScreenByID(transferScreenId); // No transfer screen indicates uncommon level design. Pacman it and just send Bonzo to the // other side of the same screen, pretending that the transfer screen is this one. if (transferScreen == null) { transferScreen = getCurrentScreen(); } changeCurrentScreen(transferScreenId, bonzo); bonzo.setCurrentLocation(transferScreen.getBonzoStartingLocationPixels() ); // Momentuem is always recent on bonus transfers transferScreen.setBonzoCameFrom( BonzoSaveState.fromPoint(transferScreen.getBonzoStartingLocationPixels() ) ); return true; } /** * * Resets the current screen. This involves not only the basic level screen reset, but it must * look for any world entities (like goodies) that need resetting if already grabbed. * */ private void resetCurrentScreen() { final LevelScreen currentScreen = getCurrentScreen(); currentScreen.resetScreen(); // reset goodies for (GoodieLocationPair pair : goodiesPerScreen.get(currentScreen.getId() ) ) { pair.goodie.resetIfApplicable(); } } /** * * Returns a listing of all the goodies that appear on the given level, including their locations. * * @param id * id of the level * * @return * goodies on level, or an empty collection if the level id does not exist * */ public Collection<GoodieLocationPair> getGoodiesForLevel(int id) { return goodiesPerScreen.get(id); } /** * Get the world name */ public String getWorldName() { return this.worldName; } /** * * Adds a level screen to the world. If the screen already exists, this method throws an exception * * @param screen * the new screen to add * * @throws IllegalArgumentException * if the given screen already exists in the world. Use {@code screenIdExists() } to check and {@code removeScreen} * first if the intent is to replace * */ public void addScreen(final LevelScreen screen) { if (this.worldScreens.containsKey(screen.getId() ) ) { throw new IllegalArgumentException("Screen id " + screen.getId() + " already exists"); } this.worldScreens.put(screen.getId(), screen); } /** * * Adds the given level screen to the world, with no checks if a screen by that id already exists in the world. If it * did, it is replaced with the new screen. * * @param screen * new screen to add * */ public void addOrReplaceScreen(final LevelScreen screen) { this.worldScreens.put(screen.getId(), screen); } /** * * Removes the given level screen from the world based on the id. If the screen does not exist, throws an exception. * <p/> * There is one very special case: screen id 1000 may NEVER be removed under any circumstances. If the intent is to replace, * use {@code addOrReplaceScreen(LevelScreen) } * * @param screenId * screen id to remove * * @throws IllegalArgumentException * if no screen by that id exists in the world, or if this tried to delete screen 1000 * */ public void removeScreen(int screenId) { if (!(this.worldScreens.containsKey(screenId) ) ) { throw new IllegalArgumentException("Screen id " + screenId + " does not exist"); } if (screenId == 1000) { throw new IllegalArgumentException("Screen 1000 may not be removed from a world, ever"); } this.worldScreens.remove(screenId); } /** * Sets the current screen for the world. This should be used sparingly (instead relying on screenChange for most cases) * and should always be followed up with a change to Bonzos location (unless called from the level editor) * <p/> * This method will perform no action and return false if the screen doesn't exist (to prevent crashes in game, but this * generally shouldn't happen on a well designed world). Otherwise, changes the screen and returns true * * @param screenId * the id of the screen to change to * * @param bonzo * reference to bonzo to update his screen location. Both values must stay synced. This May be {@code null} * only if there is no bonzo at all (such as being called from the level editor) * * @return * {@code true} if changing the screen was successful, {@code false} if the screen does not exist and thus * could not be switched * */ public boolean changeCurrentScreen(int screenId, Bonzo bonzo) { if (screenIdExists(screenId) == false) return false; else { resetCurrentScreen(); this.currentScreen = screenId; if (bonzo != null) bonzo.changeScreen(screenId); return true; } } public void checkCollisions(Bonzo theBonzo) { // Another Screen? ImmutablePoint2D currentLocation = theBonzo.getCurrentLocation(); ScreenDirection dir = ScreenDirection.fromLocation(currentLocation, Bonzo.BONZO_SIZE); if (dir != ScreenDirection.CURRENT) { int newId = dir.getNextScreenId(this.currentScreen); changeCurrentScreen(newId, theBonzo); dir.transferLocation(theBonzo.getMutableCurrentLocation(), Bonzo.BONZO_SIZE); final LevelScreen theNewScreen = getCurrentScreen(); // Update the new screen with data about where we came from so deaths bring us to the same place ImmutablePoint2D bonzoVelocity = theBonzo.getCurrentVelocity(); ImmutablePoint2D bonzoCurrentLocation = theBonzo.getCurrentLocation(); theNewScreen.setBonzoCameFrom( BonzoSaveState.of( bonzoCurrentLocation.x(), bonzoCurrentLocation.y(), bonzoVelocity.x(), bonzoVelocity.y(), theBonzo.isJumping(), theBonzo.getCurrentConveyerEffect() ) ); // If the current screen has a 'bonzo last on ground' state, reset it. Otherwise dying may bring him // to the wrong screen in the wrong part. theNewScreen.resetBonzoOnGround(); // Ignore any other collisions for now. return; } // A Sprite? List<Sprite> allSprites = getCurrentScreen().getSpritesOnScreen(); ImmutableRectangle bonzoBounding = theBonzo.getCurrentBounds(); for (Sprite nextSprite : allSprites) { Boundable intersection = nextSprite.getCurrentBounds().intersect(bonzoBounding); if (intersection != null) { // Bounding box check done. Do more expensive pixel check if (nextSprite.pixelCollision(theBonzo, intersection) ) { nextSprite.getType().onBonzoCollision(theBonzo, this); // do not do further collisions after bonzo dies break; } } } // It is entirely possible that bonzo just transferred screens from the above collision. His position // must be recomputed. currentLocation = theBonzo.getCurrentLocation(); // A hazard? hazardCollisionCheck(theBonzo); // A goodie? int topLeftX = (currentLocation.x() + (GameConstants.GOODIE_SIZE_X / 2) ) / GameConstants.GOODIE_SIZE_X; int topLeftY = (currentLocation.y() + (GameConstants.GOODIE_SIZE_Y / 2) )/ GameConstants.GOODIE_SIZE_Y; // Top-left, Top-Right, Bottom-Left, Bottom-Right WorldCoordinate[] goodieQuads = new WorldCoordinate[] { new WorldCoordinate(currentScreen, topLeftX, topLeftY), new WorldCoordinate(currentScreen, topLeftX + 1, topLeftY), new WorldCoordinate(currentScreen, topLeftX, topLeftY + 1), new WorldCoordinate(currentScreen, topLeftX + 1, topLeftY + 1) }; // Add to the total number of goodies the player has collected, provided the goodie actually grants non-zero // score. for (WorldCoordinate quad : goodieQuads) { Goodie gotGoodie; if ( (gotGoodie = goodiesInWorld.get(quad) ) != null ) { if (gotGoodie.take(theBonzo, this) ) { if (gotGoodie.getGoodieType().score > 0) ++goodiesCollected; } } } } public int getGoodiesCollected() { return goodiesCollected; } /** * * Performs a check if the bonzo is on one or more 'hazard' tiles. If so, then the hazard it set * to explode (if required) and bonzo is killed based on the hazard properties. * * @param bonzo * */ private void hazardCollisionCheck(Bonzo bonzo) { ImmutablePoint2D[] tilesToCheck = effectiveTilesCollision(bonzo.getCurrentBounds() ); final TileMap map = getCurrentScreen().getMap(); for (ImmutablePoint2D tile : tilesToCheck) { TileType type = map.getTileXY(tile.x(), tile.y() ); if (type instanceof HazardTile) { // Still can get out of doing anything if the hazard is already gone. HazardTile hazard = (HazardTile) type; // MUST check isExploding. If bonzo had invincibility and lost it 1 tick after touching // a bomb, the bomb is technically already no longer a hurt for Bonzo. if (hazard.isDead() || hazard.isExploding() ) continue; hazard.hazardHit(rsrc.getSoundManager() ); // Last check; is this hazard harmless? Harmless hazards still play hit sounds and explode, hence why // we did not check earlier. if (!(hazard.getHazard().isHarmless() ) ) { // Send a kill message to bonzo. Only invincibility will save him bonzo.tryKill(hazard.getHazard().getDeathAnimation() ); } return; } } } /** * * Resolves a bounding box into its 'effective' four tiles it takes up. The bounding box MUST be 40x40; any * other size will have unexpected behaviour (and fire an assertion error is assertions are enabled. * <p/> * A bounding box may cover more than four tiles. However, the four chosen will be the four 'most' covered by * the box. This is a 'good enough' representation of where Bonzo is, and is typically used for things like * hazards. * <p/> * Generally, the way the system works (moving between screens) this method should rarely end up enumerating * a tile grid location that is outside of the number of actual tiles on the screen. However, fast speeds * downwards MAY cause this to happen; it is important for clients to handle the case where any of the returned * points may be out of range. * * @param bounds * the bounding rectangle. MUST be 40x40 * * @return * array of size 4, from top-left clockwise, each 'point' that represents an x,y in the tile gride of * the file this bounding box occupies * * * @throws AssertionError * if assertions are enabled and the bounds are not 40x40 * */ static ImmutablePoint2D[] effectiveTilesCollision(ImmutableRectangle bounds) { assert bounds.getSize().x() == 40; assert bounds.getSize().y() == 40; // Solution: // 1) 'snap' top left x,y cordinates. Whether the x/y stays in the grid tile, or moves right/ // down depends on how close the position would be to the other. // 2) Divide to get the tile x,y, then build the other three points in clockwise form // // Stays in tile... // *-------* // | X- | // | | | // | | // *-------* // // Snaps to right and bottom tile // *-------* // | | // | | // | X-| // *-----|-* IPoint2D topLeft = bounds.getLocation(); int offsetInTileX = topLeft.x() % GameConstants.TILE_SIZE_X; int offsetInTileY = topLeft.y() % GameConstants.TILE_SIZE_Y; // The use of > means that tile snapping favours staying within a tile // [0, TILE_SIZE_X_HALF] vs snapping (TILE_SIZE_X_HALF, TILE_SIZE_X] // Division transforms absolute point to grid point int newTopLeftX = ( offsetInTileX > GameConstants.TILE_SIZE_X_HALF ? (topLeft.x() / GameConstants.TILE_SIZE_X) + 1 : topLeft.x() / GameConstants.TILE_SIZE_X); int newTopLeftY = ( offsetInTileY > GameConstants.TILE_SIZE_Y_HALF ? (topLeft.y() / GameConstants.TILE_SIZE_Y) + 1 : topLeft.y() / GameConstants.TILE_SIZE_Y); ImmutablePoint2D[] fourPoints = new ImmutablePoint2D[4]; fourPoints[0] = ImmutablePoint2D.of(newTopLeftX, newTopLeftY); fourPoints[1] = ImmutablePoint2D.of(newTopLeftX + 1, newTopLeftY); fourPoints[2] = ImmutablePoint2D.of(newTopLeftX, newTopLeftY + 1); fourPoints[3] = ImmutablePoint2D.of(newTopLeftX + 1, newTopLeftY + 1); return fourPoints; } /** * * Adds a goodie to the given world, typically only used by level editor. * * @param screenId * @param row (also known as x) * @param col (also known as y) * @param type * */ public void addGoodie(final int screenId, final int row, final int col, final Goodie.Type type) { WorldCoordinate coordinate = new WorldCoordinate(screenId, row, col); // If goodie already exists, take out and replace removeGoodie(screenId, row, col); Goodie newGoodie = Goodie.newGoodie(type, ImmutablePoint2D.of(row, col), screenId, rsrc); goodiesInWorld.put(coordinate, newGoodie); goodiesPerScreen.put(screenId, new GoodieLocationPair(newGoodie, coordinate) ); } /** * * Removes a goodie at the given position for the given screen. Goodie will be removed from all relevant structures. * If there is no goodie at the given location, this method does nothing. * * @param screenId * @param row * @param col * */ public void removeGoodie(final int screenId, final int row, final int col) { WorldCoordinate coordinate = new WorldCoordinate(screenId, row, col); if (goodiesInWorld.get(coordinate) != null) { goodiesInWorld.remove(coordinate); // We still have this goodie lurking somewhere in the other structure. Remove it there too. Collection<GoodieLocationPair> screenGoodies = goodiesPerScreen.get(screenId); for (Iterator<GoodieLocationPair> pairIt = screenGoodies.iterator(); pairIt.hasNext(); /* No op */ ) { GoodieLocationPair pair = pairIt.next(); if (pair.location.getRow() == row && pair.location.getCol() == col) { pairIt.remove(); // Only one to find. No need to keep searching there are not duplicates. break; } } } } /** * * Called when this world is over, as in bonzo died, left, whatever. It is up to clients to decide when a world is done. * When it is, final statistics computations are done and become available. * <p/> * Does nothing if the world is already finished. * * @param bonzo * reference to bonzo. Some of his data is used in stats calculations after finishing * */ public void worldFinished(Bonzo bonzo) { worldFinished = true; stats = new WorldStatistics( goodiesInWorld.values(), goodiesCollected, bonzo.getScore(), bonusCountdown, bonzo.getLives() == Bonzo.INFINITE_LIVES ? true : false); } /** * * Determines if the world is finished and final statistical computations are available. * * @return * {@code true} if the world is finished form a previous call to {@code worldFinished}, * {@code false} if otherwise. * */ public boolean isWorldFinished() { return worldFinished; } /** * * Returns a statistics object after the world was over that indicates points and totals, with all multipliers applied * as required. Intended for the final tally screen as well as to set the high score. * <p/> * This object is only available if {@code isWorldFinished} is {@code true}. Otherwise, calling this method is * an error. * * @return * statistics of the finished world. Never {@code null}, but check preconditions to ensure the method * does not throw an exception * * @throws IllegalStateException * if the world is not finished yet, and hence no statistics are available. * */ public WorldStatistics getStatistics() { if (stats == null) throw new IllegalStateException("World should be finished before calling this method"); return stats; } /** * * Paints the world as it currently is to the given graphics context. This should be called on each update or * faster for a smooth experience. * * @param g2d * */ public void paint(Graphics2D g2d) { getCurrentScreen().paint(g2d); // TODO group goodies into a better collection based on screen Collection<Goodie> goodiesVector = (Collection<Goodie>)goodiesInWorld.values(); for (Goodie nextGoodie : goodiesVector) { if (nextGoodie.getScreenID() == currentScreen) { nextGoodie.paint(g2d); } } } /** * * Should be called every tick at {@code GameConstants.GAME_SPEED}. Updates the game. Each update * call is one tick of game time. * */ public void update() { getCurrentScreen().update(); // TODO group goodies into a better collection based on screen Collection<Goodie> goodiesVector = (Collection<Goodie>)goodiesInWorld.values(); for (Goodie nextGoodie : goodiesVector) { if (nextGoodie.getScreenID() == currentScreen) { nextGoodie.update(); } } } /** * Returns an unmodifiable view of the current state of the goodies in this world. Clients can not modify the goodies * in this world through the returned map. * * @return * immutable copy of the map representing the goodies in this world */ public Map<WorldCoordinate, Goodie> getGoodies() { return Collections.unmodifiableMap(this.goodiesInWorld); } /** * Returns an immutable copy of all the levels in the world * * @return */ public Map<Integer, LevelScreen> getLevelScreens() { return Collections.unmodifiableMap(this.worldScreens); } /** * Returns an unmodifiable version of the list of hazards in this world. This is not their locations; that is in tile data * as a {@code HazardTile}. This describes the 'types' of hazards (bombs, lava) in a world. * * @return */ public List<Hazard> getHazards() { return Collections.unmodifiableList(this.hazards); } /** * Returns an unmodifiable version of the list of conveyers in the world. * Remember that each type (id) of conveyer is represented by two distinct conveyers; one moving clockwise and the next moving anti-clockwise * * @return */ public List<Conveyer> getConveyers() { return Collections.unmodifiableList(this.conveyers); } /** * * <strong> intended only for use by level editor</strong> * <p/> * Sets the given hazards available for the world. This modifies the internal list, and does not store a reference to * the passed one. * * @param hazards * new list of hazards * */ public void setHazards(List<Hazard> newHazards) { this.hazards.clear(); this.hazards.addAll(newHazards); } /** * * <strong> only intended for use by translation utilities for original Monkey Shines file format </strong> * <p/> * Goes through every level in the world, and for each placeholder tile replaces it with a real version. Please * read the docs on {@code PlaceholderTile} for an explanation of why this is used. This is an expensive operation * and should be done only when the hazards and conveyer lists are finalised, and all the level data has been * added. * <p/> * At the conclusion of this method, the world will have no more placeholder tiles in any of its levels. * * @throws WorldTranslationException * if placeholders cannot be fixed because the resource pack did not define enough of either hazards or * conveyer belts * */ public void fixPlaceholders() throws WorldTranslationException { for (LevelScreen lvl : worldScreens.values() ) { TileMap tileMap = lvl.getMap(); // We iterate and assign internally because this is such a specific case that it isn't relevant to // be part of TileMap API TileType[] map = tileMap.internalMap(); final int size = tileMap.getRowCount() * tileMap.getColumnCount(); for (int i = 0; i < size; ++i) { if (map[i] instanceof PlaceholderTile) { int metadata = ((PlaceholderTile)map[i]).getMetaId(); PlaceholderTile.Type type = (((PlaceholderTile)map[i])).getType(); switch (type) { case HAZARD: if (metadata >= this.hazards.size() ) { throw new WorldTranslationException(TranslationFailure.TRANSLATOR_SPECIFIC, "Not enough hazards defined in resource pack. Must have at least " + (metadata + 1) ); } map[i] = HazardTile.forHazard(this.hazards.get(metadata) ); break; case CONVEYER_ANTI_CLOCKWISE: { int index = (metadata * 2) + 1; if (index >= this.conveyers.size() ) { throw new WorldTranslationException(TranslationFailure.TRANSLATOR_SPECIFIC, "Not enough unique conveyers defined in resource pack. Must have at least " + (metadata + 1) ); } map[i] = new ConveyerTile(this.conveyers.get(index) ); break; } case CONVEYER_CLOCKWISE: { int index = (metadata * 2); if (index >= this.conveyers.size() ) { throw new WorldTranslationException(TranslationFailure.TRANSLATOR_SPECIFIC, "Not enough unique conveyers defined in resource pack. Must have at least " + (metadata + 1) ); } map[i] = new ConveyerTile(this.conveyers.get(index) ); break; } default: throw new RuntimeException("Unknown enumeration " + type + " for fixing placeholders"); } } } } } /** * * Provides a pairing of a location in the world to a goodie. This is NOT used in normal calculations (cooridnates are * in a map and obtained via checking the map). This is intended for when a system needs to know both the goodies * and the locations of the goodies on a specific screen only. * * @author Erika Redmark * */ public static final class GoodieLocationPair { public final Goodie goodie; public final WorldCoordinate location; private GoodieLocationPair(final Goodie goodie, final WorldCoordinate location) { this.goodie = goodie; this.location = location; } } /*************** * Private Data **************/ private final String worldName; private final Map<WorldCoordinate, Goodie> goodiesInWorld; private int goodiesCollected; // Holds a list of all goodies on a particlar screen Id. During screen reset, relevant goodies may // need to be regenerated. // Goodies removed from a world are also removed from this map in parallel. This acts only as an optimisation so that // all goodies in a screen can be looked at at once (typically for drawing or updating) private final Multimap<Integer, GoodieLocationPair> goodiesPerScreen; // When a world is initialised, hold a set of all blue and red keys. When taken, they will // be removed from the set. The moment a set becomes empty, it toggles the 'all blue keys' or // 'all red keys' collected event. // NOTE: If the editor adds keys, this goes out of sync. IT DOESN'T MATTER. When the level is saved // and reloaded, this object is re-initialised for gameplay with the right values and keys can't be // added during gameplay. private final Set<Goodie> redKeys; private final Set<Goodie> blueKeys; // Screens: Hashmap. That way, when moving across screens, take the levelid and add/subtract a value, check it in hash, // and quickly get the screen we need. It is fast and I believe the designers of the original did the same thing. private final Map<Integer, LevelScreen> worldScreens; // Each hazard tile references the hazard it needs, but the hazards themselves are part of the world. // Typically, a world includes hazard ids for dynamite, bombs, lightbulbs, and sometimes lava, although // others may be added by the level editor, along with custom graphics in the graphics pack. private final List<Hazard> hazards; // Similiar to, but less complicated than, hazards, each conveyer tile is represented by // a single conveyer immutable state. In this case, this is which kind of conveyer belt it // is and which direction it is moving. // Populated automatically conveyers up to the greater of the two: // number of conveyer belt types in the original world file // number of conveyer belt types in the resource when being skinned. // Changing the resource to have less conveyers whilst conveyer belt tiles are on the world referring // to them is probably a bad idea. private final List<Conveyer> conveyers; private int currentScreen; // This is defaults to either 10000 or from the save file. It is up to the level editor // to set this. However, it should do so automatically on every save. // Effectively final for game, mutable for level editor private int bonusScreen; // Screen bonzo returns to when leaving bonus world. This is automatically set to the screen bonzo first // enters a bonus door. This is done so the level editor user doesn't need to set both screens. // This MAY be null, in which case it is awaiting initial setting. // In practise, the entry // to the bonus world is always on the same screen Id (as the bonus world has its own bonus door // somewhere that must take bonzo back), but this is left dynamically set in case two bonus doors // are to link to a dead-end bonus room. private Integer returnScreen; // When a world is first created, it has an associated bonus countdown of 10000. Once all red keys are collected, // the main game session will start to decrement this every second or so. private int bonusCountdown; // Lists of all bonus and exit doors so that setting them visible when all keys are collected doesn't // require iterating over every sprite in the world. private final List<Sprite> bonusDoors = new ArrayList<>(4); // initial size 4. 2 bonus doors, possibly double doored sprites for some worlds. private final List<Sprite> exitDoors = new ArrayList<>(4); // Just in case multiple exits, or exit made up of multiple sprites. // Intended for callback to UI when certain victory or defeat conditions are met // not set in constructor; will not be run if never set. private Runnable allRedKeysCollectedCallback; // The author of the world. Defaults to "Unknown" private String author; private final WorldResource rsrc; // This field is ONLY created after the game is over. See javadocs on accessor methods. private WorldStatistics stats; private boolean worldFinished; }