package com.homescreenarcade.pinball; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.PriorityQueue; import java.util.Random; import java.util.Set; import com.badlogic.gdx.math.Vector2; import com.badlogic.gdx.physics.box2d.Body; import com.badlogic.gdx.physics.box2d.Contact; import com.badlogic.gdx.physics.box2d.ContactImpulse; import com.badlogic.gdx.physics.box2d.ContactListener; import com.badlogic.gdx.physics.box2d.Fixture; import com.badlogic.gdx.physics.box2d.Manifold; import com.badlogic.gdx.physics.box2d.World; import com.homescreenarcade.ArcadeCommon; import com.homescreenarcade.pinball.elements.DropTargetGroupElement; import com.homescreenarcade.pinball.elements.FieldElement; import com.homescreenarcade.pinball.elements.FlipperElement; import com.homescreenarcade.pinball.elements.RolloverGroupElement; import com.homescreenarcade.pinball.elements.SensorElement; import android.content.Context; import android.content.Intent; import android.support.v4.content.LocalBroadcastManager; public class Field implements ContactListener { FieldLayout layout; World world; Set<Body> layoutBodies; List<Ball> balls = new ArrayList<>(); Set<Body> ballsAtTargets; // Allow access to model objects from Box2d bodies. Map<Body, FieldElement> bodyToFieldElement; Map<String, FieldElement> fieldElementsByID; Map<String, List<FieldElement>> elementsByGroupID = new HashMap<String, List<FieldElement>>(); // Store FieldElements in arrays for optimized iteration. FieldElement[] fieldElementsArray; FieldElement[] fieldElementsToTick; Random RAND = new Random(); long gameTime; // Actions scheduled to occur at specific times in the future. PriorityQueue<ScheduledAction> scheduledActions; Delegate delegate; GameState gameState = new GameState(); GameMessage gameMessage; // Used in checkForStuckBall() to see if the ball hasn't moved recently. float lastBallPositionX; float lastBallPositionY; long nanosSinceBallMoved = -1; // Duration after which the ball is considered stuck if it hasn't moved significantly, // if it's a single ball and no flippers are active. Normally the time ratio is around 2, // so this will be about 5 real-world seconds. static final long STUCK_BALL_NANOS = 10000000000L; AudioPlayer audioPlayer = AudioPlayer.NoOpPlayer.getInstance(); Clock clock = Clock.SystemClock.getInstance(); // Interface to allow custom behavior for various game events. public static interface Delegate { public void gameStarted(Field field); public void ballLost(Field field); public void gameEnded(Field field); public void tick(Field field, long nanos); public void processCollision(Field field, FieldElement element, Body hitBody, Ball ball); public void flippersActivated(Field field, List<FlipperElement> flippers); public void allDropTargetsInGroupHit(Field field, DropTargetGroupElement targetGroup); public void allRolloversInGroupActivated(Field field, RolloverGroupElement rolloverGroup); public void ballInSensorRange(Field field, SensorElement sensor, Ball ball); public boolean isFieldActive(Field field); } // Helper class to represent actions scheduled in the future. static class ScheduledAction implements Comparable<ScheduledAction> { Long actionTime; Runnable action; @Override public int compareTo(ScheduledAction another) { // Sort by action time so these objects can be inserted into a PriorityQueue. return actionTime.compareTo(another.actionTime); } } /** * Creates Box2D world, reads layout definitions for the given level, and initializes the game * to the starting state. */ public void resetForLevel(Context context, int level) { Vector2 gravity = new Vector2(0.0f, -1.0f); boolean doSleep = true; world = new World(gravity, doSleep); world.setContactListener(this); gameState.statusMgr = LocalBroadcastManager.getInstance(context); Intent scoreIntent = new Intent(ArcadeCommon.ACTION_STATUS) .putExtra(ArcadeCommon.STATUS_RESET_SCORE, true) .putExtra(ArcadeCommon.STATUS_LEVEL, level) .putExtra(ArcadeCommon.STATUS_LIVES, gameState.totalBalls - gameState.ballNumber); gameState.statusMgr.sendBroadcast(scoreIntent); layout = FieldLayout.layoutForLevel(level, world); world.setGravity(new Vector2(0.0f, -layout.getGravity())); ballsAtTargets = new HashSet<Body>(); scheduledActions = new PriorityQueue<ScheduledAction>(); gameTime = 0; // Map bodies and IDs to FieldElements, and get elements on whom tick() has to be called. bodyToFieldElement = new HashMap<Body, FieldElement>(); fieldElementsByID = new HashMap<String, FieldElement>(); List<FieldElement> tickElements = new ArrayList<FieldElement>(); for(FieldElement element : layout.getFieldElements()) { if (element.getElementId()!=null) { fieldElementsByID.put(element.getElementId(), element); } for(Body body : element.getBodies()) { bodyToFieldElement.put(body, element); } if (element.shouldCallTick()) { tickElements.add(element); } } fieldElementsToTick = tickElements.toArray(new FieldElement[0]); fieldElementsArray = layout.getFieldElements().toArray(new FieldElement[0]); delegate = null; String delegateClass = layout.getDelegateClassName(); if (delegateClass!=null) { if (delegateClass.indexOf('.')==-1) { delegateClass = "com.homescreenarcade.pinball.fields." + delegateClass; } try { delegate = (Delegate)Class.forName(delegateClass).newInstance(); } catch(Exception ex) { throw new RuntimeException(ex); } } else { // Use no-op delegate if no class specified, so that field.getDelegate() is non-null. delegate = new BaseFieldDelegate(); } } private void _startGame(boolean unlimitedBalls) { gameState.setTotalBalls(layout.getNumberOfBalls()); gameState.setUnlimitedBalls(unlimitedBalls); gameState.startNewGame(); getDelegate().gameStarted(this); } public void startGame() { _startGame(false); } public void startGameWithUnlimitedBalls() { _startGame(true); } /** * Returns the FieldElement with the given value for its "id" attribute, or null if there is * no such element. */ public FieldElement getFieldElementById(String elementID) { return fieldElementsByID.get(elementID); } /** * Called to advance the game's state by the specified number of nanoseconds. iters is the * number of times to call the Box2D World.step method; more iterations produce better accuracy. * After updating physics, processes element collisions, calls tick() on every FieldElement, * and performs scheduled actions. */ void tick(long nanos, int iters) { float dt = (nanos/1e9f) / iters; for(int i=0; i<iters; i++) { clearBallContacts(); world.step(dt, 10, 10); processBallContacts(); } gameTime += nanos; processElementTicks(); processScheduledActions(); processGameMessages(); checkForStuckBall(nanos); getDelegate().tick(this, nanos); } /** Calls the tick() method of every FieldElement in the layout. */ void processElementTicks() { int size = fieldElementsToTick.length; for(int i=0; i<size; i++) { fieldElementsToTick[i].tick(this); } } /** * Runs actions that were scheduled with scheduleAction and whose execution time has arrived. */ void processScheduledActions() { while (true) { ScheduledAction nextAction = scheduledActions.peek(); if (nextAction!=null && gameTime >= nextAction.actionTime) { scheduledActions.poll(); nextAction.action.run(); } else { break; } } } /** * Schedules an action to be run after the given interval in milliseconds has elapsed. * Interval is in game time, not real time. */ public void scheduleAction(long interval, Runnable action) { ScheduledAction sa = new ScheduledAction(); // interval is in milliseconds, gameTime is in nanoseconds sa.actionTime = gameTime + (interval * 1000000); sa.action = action; scheduledActions.add(sa); } /** * Launches a new ball. The position and velocity of the ball are controlled by the parameters * in the field layout JSON. */ public Ball launchBall() { List<Float> position = layout.getLaunchPosition(); List<Float> velocity = layout.getLaunchVelocity(); float radius = layout.getBallRadius(); Ball ball = Ball.create(world, position.get(0), position.get(1), radius, layout.getBallColor(), layout.getSecondaryBallColor()); ball.getBody().setLinearVelocity(new Vector2(velocity.get(0), velocity.get(1))); this.balls.add(ball); audioPlayer.playBall(); return ball; } /** Removes a ball from play. If there are no other balls on the field, calls doBallLost. */ public void removeBall(Ball ball) { world.destroyBody(ball.getBody()); this.balls.remove(ball); if (this.balls.size()==0) { this.doBallLost(); } } /** * Removes a ball from play, but does not call doBallLost for end-of-ball processing even if * no balls remain. */ public void removeBallWithoutBallLoss(Ball ball) { world.destroyBody(ball.getBody()); this.balls.remove(ball); } /** * Called when a ball has ended. Ends the game if that was the last ball, otherwise updates * GameState to the next ball. Shows a game message to indicate the ball number or game over. */ public void doBallLost() { boolean hasExtraBall = (this.gameState.getExtraBalls() > 0); this.gameState.doNextBall(); // display message for next ball or game over String msg = null; if (hasExtraBall) msg = "Shoot Again"; else if (this.gameState.isGameInProgress()) msg = "Ball " + this.gameState.getBallNumber(); if (msg!=null) { // game is still going, show message after delay final String msg2 = msg; // must be final for closure, yay Java this.scheduleAction(1500, new Runnable() { @Override public void run() { showGameMessage(msg2, 1500, false); // no sound effect } }); } else { if (gameState.statusMgr != null) { Intent scoreIntent = new Intent(ArcadeCommon.ACTION_STATUS) .putExtra(ArcadeCommon.STATUS_LIVES, -1); gameState.statusMgr.sendBroadcast(scoreIntent); } endGame(); } getDelegate().ballLost(this); } /** * Returns true if there are active elements in motion. Returns false if there are no active * elements, indicating that tick() can be called with larger time steps, less frequently, or * not at all. */ public boolean hasActiveElements() { // HACK: to allow flippers to drop properly at beginning of game, we need accurate simulation. if (this.gameTime < 500) return true; // Allow delegate to return true even if there are no balls. if (getDelegate().isFieldActive(this)) return true; return this.getBalls().size() > 0; } ArrayList<Ball> deadBalls = new ArrayList<Ball>(); // avoid allocation every time /** * Removes balls that are not in play, as determined by optional "deadzone" property of * launch parameters in field layout. */ public void removeDeadBalls() { List<Float> deadRect = layout.getLaunchDeadZone(); if (deadRect==null) return; for(int i=0; i<this.balls.size(); i++) { Ball ball = this.balls.get(i); Vector2 bpos = ball.getPosition(); if (bpos.x > deadRect.get(0) && bpos.y > deadRect.get(1) && bpos.x < deadRect.get(2) && bpos.y < deadRect.get(3)) { deadBalls.add(ball); world.destroyBody(ball.getBody()); } } for(int i=0; i<deadBalls.size(); i++) { this.balls.remove(deadBalls.get(i)); } deadBalls.clear(); } /** Called by FieldView to draw the balls currently in play. */ public void drawBalls(IFieldRenderer renderer) { for(int i=0; i<this.balls.size(); i++) { this.balls.get(i).draw(renderer); } } ArrayList<FlipperElement> activatedFlippers = new ArrayList<FlipperElement>(); /** * Called to engage or disengage all flippers. If called with an argument of true, and all * flippers were not previously engaged, calls the flipperActivated methods of all field * elements and the field's delegate. */ public void setFlippersEngaged(List<FlipperElement> flippers, boolean engaged) { activatedFlippers.clear(); boolean allFlippersPreviouslyActive = true; int fsize = flippers.size(); for(int i=0; i<fsize; i++) { FlipperElement flipper = flippers.get(i); if (!flipper.isFlipperEngaged()) { allFlippersPreviouslyActive = false; if (engaged) { activatedFlippers.add(flipper); } } flipper.setFlipperEngaged(engaged); } if (engaged && !allFlippersPreviouslyActive) { audioPlayer.playFlipper(); for(FieldElement element : this.getFieldElementsArray()) { element.flippersActivated(this, activatedFlippers); } getDelegate().flippersActivated(this, activatedFlippers); } } public void setAllFlippersEngaged(boolean engaged) { setFlippersEngaged(this.getFlipperElements(), engaged); } public void setLeftFlippersEngaged(boolean engaged) { setFlippersEngaged(layout.getLeftFlipperElements(), engaged); } public void setRightFlippersEngaged(boolean engaged) { setFlippersEngaged(layout.getRightFlipperElements(), engaged); } /** * Ends a game in progress by removing all balls in play, calling setGameInProgress(false) * on the GameState, and setting a "Game Over" message for display by the score view. */ public void endGame() { audioPlayer.playStart(); // play startup sound at end of game for(Ball ball : this.getBalls()) { world.destroyBody(ball.getBody()); } this.balls.clear(); this.getGameState().setGameInProgress(false); this.showGameMessage("Game Over", 2500); if (delegate != null) { delegate.gameEnded(this); } } /** Adjusts gravity in response to the device being tilted; not currently used. */ public void receivedOrientationValues(float azimuth, float pitch, float roll) { double angle = roll - Math.PI/2; float gravity = layout.getGravity(); float gx = (float)(gravity * Math.cos(angle)); float gy = -Math.abs((float)(gravity * Math.sin(angle))); world.setGravity(new Vector2(gx, gy)); } // Contact support. Keep parallel lists of balls and the fixtures they contact. // A ball can have multiple contacts in the same tick, e.g. against two walls. ArrayList<Ball> contactedBalls = new ArrayList<Ball>(); ArrayList<Fixture> contactedFixtures = new ArrayList<Fixture>(); void clearBallContacts() { contactedBalls.clear(); contactedFixtures.clear(); } /** * Called after Box2D world step method, to notify FieldElements that the ball collided with. */ void processBallContacts() { for(int i=0; i<contactedBalls.size(); i++) { Ball ball = contactedBalls.get(i); Fixture f = contactedFixtures.get(i); FieldElement element = bodyToFieldElement.get(f.getBody()); if (element!=null) { element.handleCollision(ball, f.getBody(), this); if (delegate!=null) { delegate.processCollision(this, element, f.getBody(), ball); } if (element.getScore()!=0) { this.gameState.addScore(element.getScore()); audioPlayer.playScore(); } } } } private Ball ballWithBody(Body body) { for (int i=0; i<this.balls.size(); i++) { Ball ball = this.balls.get(i); if (ball.getBody() == body) { return ball; } } return null; } // Box2D ContactListener methods. @Override public void beginContact(Contact contact) { // Nothing here, contact is recorded in endContact(). } @Override public void endContact(Contact contact) { Fixture fixture = null; Ball ball = ballWithBody(contact.getFixtureA().getBody()); if (ball != null) { fixture = contact.getFixtureB(); } else { ball = ballWithBody(contact.getFixtureB().getBody()); if (ball != null) { fixture = contact.getFixtureA(); } } if (ball != null) { contactedBalls.add(ball); contactedFixtures.add(fixture); } } @Override public void postSolve(Contact arg0, ContactImpulse arg1) { // Not used. } @Override public void preSolve(Contact arg0, Manifold arg1) { // Not used. } // End ContactListener methods. /** * Displays a message in the score view for the specified duration in milliseconds. * Duration is in real world time, not simulated game time. */ public void showGameMessage(String text, long duration, boolean playSound) { if (playSound) audioPlayer.playMessage(); gameMessage = new GameMessage(); gameMessage.text = text; gameMessage.duration = duration; gameMessage.creationTime = clock.currentTimeMillis(); } public void showGameMessage(String text, long duration) { showGameMessage(text, duration, true); } /** Updates time remaining on current game message, and removes it if expired. */ void processGameMessages() { if (gameMessage!=null) { if (clock.currentTimeMillis() - gameMessage.creationTime > gameMessage.duration) { gameMessage = null; } } } /** * Checks whether the ball appears to be stuck, and nudges it if so. */ void checkForStuckBall(long nanos) { // Only do this for single balls. This means it's theoretically possible for multiple // balls to be simultaneously stuck during multiball; that would be impressive. if (this.getBalls().size() != 1) { nanosSinceBallMoved = -1; return; } Ball ball = this.getBalls().get(0); Vector2 pos = ball.getPosition(); if (nanosSinceBallMoved < 0) { // New ball. lastBallPositionX = pos.x; lastBallPositionY = pos.y; nanosSinceBallMoved = 0; return; } if (ball.getLinearVelocity().len2() > 0.01f || pos.dst2(lastBallPositionX, lastBallPositionY) > 0.01f) { // Ball has moved since last time; reset counter. lastBallPositionX = pos.x; lastBallPositionY = pos.y; nanosSinceBallMoved = 0; return; } // Don't add time if any flipper is activated (the flipper could be trapping the ball). List<FlipperElement> flippers = this.getFlipperElements(); for (int i=0; i<flippers.size(); i++) { if (flippers.get(i).isFlipperEngaged()) return; } // Increment time counter and bump if the ball hasn't moved in a while. nanosSinceBallMoved += nanos; if (nanosSinceBallMoved > STUCK_BALL_NANOS) { showGameMessage("Bump!", 1000); // Could make the bump impulse table-specific if needed. Vector2 impulse = new Vector2(RAND.nextBoolean() ? 1f : -1f, 1.5f); ball.applyLinearImpulse(impulse); nanosSinceBallMoved = 0; } } public void addExtraBall() { gameState.setExtraBalls(gameState.getExtraBalls() + 1); } /** * Adds the given value to the game score. The value is multiplied by the current multiplier. */ public void addScore(long s) { gameState.addScore(s); } public long getScore() { return gameState.getScore(); } public void incrementScoreMultiplier() { gameState.incrementScoreMultiplier(); } public double getScoreMultiplier() { return gameState.getScoreMultiplier(); } public void setScoreMultiplier(double multiplier) { gameState.setScoreMultiplier(multiplier); } // Accessors. public float getWidth() { return layout.getWidth(); } public float getHeight() { return layout.getHeight(); } public Set<Body> getLayoutBodies() { return layoutBodies; } public List<Ball> getBalls() { return balls; } public FieldLayout getFieldLayout() { return layout; } public List<FlipperElement> getFlipperElements() { return layout.getFlipperElements(); } public List<FieldElement> getFieldElements() { return layout.getFieldElements(); } public FieldElement[] getFieldElementsArray() { return fieldElementsArray; } public GameMessage getGameMessage() { return gameMessage; } public GameState getGameState() { return gameState; } public long getGameTime() { return gameTime; } public float getTargetTimeRatio() { if (layout == null) { return 1; } else { return layout.getTargetTimeRatio(); } } public World getBox2DWorld() { return world; } public Delegate getDelegate() { return delegate; } public Object getValueWithKey(String key) { return layout.getValueWithKey(key); } public AudioPlayer getAudioPlayer() { return audioPlayer; } public void setAudioPlayer(AudioPlayer player) { audioPlayer = player; } public Clock getClock() { return clock; } public void setClock(Clock clock) { this.clock = clock; } }