/* * Copyright (C) 2016 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.android.apps.santatracker.doodles.pursuit; import android.animation.ValueAnimator; import android.animation.ValueAnimator.AnimatorUpdateListener; import android.content.Context; import android.content.res.Resources; import android.graphics.Point; import android.os.Build; import android.os.Vibrator; import android.util.Log; import android.view.MotionEvent; import android.view.View; import android.view.animation.AccelerateDecelerateInterpolator; import android.widget.TextView; import com.google.android.apps.santatracker.doodles.R; import com.google.android.apps.santatracker.doodles.pursuit.RunnerActor.RunnerState; import com.google.android.apps.santatracker.doodles.pursuit.RunnerActor.RunnerType; import com.google.android.apps.santatracker.doodles.shared.Actor; import com.google.android.apps.santatracker.doodles.shared.ActorHelper; import com.google.android.apps.santatracker.doodles.shared.ActorTween; import com.google.android.apps.santatracker.doodles.shared.ActorTween.Callback; import com.google.android.apps.santatracker.doodles.shared.AndroidUtils; import com.google.android.apps.santatracker.doodles.shared.AnimatedSprite; import com.google.android.apps.santatracker.doodles.shared.Camera; import com.google.android.apps.santatracker.doodles.shared.CameraShake; import com.google.android.apps.santatracker.doodles.shared.EmptyTween; import com.google.android.apps.santatracker.doodles.shared.EventBus; import com.google.android.apps.santatracker.doodles.shared.FakeButtonActor; import com.google.android.apps.santatracker.doodles.shared.GameFragment; import com.google.android.apps.santatracker.doodles.shared.Interpolator; import com.google.android.apps.santatracker.doodles.shared.RectangularInstructionActor; import com.google.android.apps.santatracker.doodles.shared.Sprites; import com.google.android.apps.santatracker.doodles.shared.Tween; import com.google.android.apps.santatracker.doodles.shared.TweenManager; import com.google.android.apps.santatracker.doodles.shared.UIUtil; import com.google.android.apps.santatracker.doodles.shared.Vector2D; import java.text.NumberFormat; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; /** * All the game logic for the second version of the running game. Mostly consists of managing * a list of Actors. */ public class PursuitModel { private static final String TAG = PursuitModel.class.getSimpleName(); /** * High-level phases of the game are controlled by a state machine which uses these states. */ public enum State { TITLE, SETUP, READY, RUNNING, SUCCESS, FAIL, } /** * Will be notified when game enters a new state. */ public interface StateChangedListener { void onStateChanged(State state); } /** * Will be notified when the score changes. */ public interface ScoreListener { void newScore(float timeInSeconds); } /** * The other fruit the player is racing against. */ public class OpponentActor extends RunnerActor { // Flag that indicates whether the opponent has crossed the finishing line. // Used for decelerating the opponent. public boolean isFinished; OpponentActor(Resources resources, RunnerType type, int lane) { super(resources, type, lane); isFinished = false; } } // Game-world uses a fixed coordinate system which, for simplicity, matches the resolution of the // art assets (i.e. 1 pixel in the art == 1 game-world unit). Measurements from art assets // directly translate to game-world units. Example: player1.png's racket is 57 pixels from the // bottom, so if the ball is 57 game-world units above the bottom of player's sprite, the ball is // at the same height as the racket). The view is responsible for scaling to fit the screen. // Visualization Constants public static final int HEIGHT = 960; public static final int WIDTH = 540; public static final int HALF_WIDTH = WIDTH / 2; public static final int HALF_HEIGHT = HEIGHT / 2; public static final int EXPECTED_PLAYER_WIDTH = (int) (WIDTH * 0.14f); // UI Constants private static final float BUTTON_SCALE = 0.8f; private static final float TUTORIAL_DURATION = 4f; private static final int COUNTDOWN_LABEL_COLOR = 0xffffbb39; // High level Android variables that lays the foundation for the game. private final Resources resources; private final TweenManager tweenManager = new TweenManager(); private final Vibrator vibrator; private final EventBus eventBus; private final Locale locale; // Temporary Power-up generation variables that will be replaced by a more robust system. private float powerUpTimer; private int mapRow = 0; private PursuitLevel map; // Gameplay Constants. // Currently this game is divided into lanes. // Power ups are added at the center of each discrete lane. // As the game speed is increased, more lanes are added, increasing the gameplay area. public static final int NUM_LANES = 3; public static final int MIN_LANE = 0; public static final int MAX_LANE = NUM_LANES - 1; public static final int INITIAL_LANE = NUM_LANES / 2; public static final int LANE_SIZE = WIDTH / (NUM_LANES + 2); // All duration in seconds. private static final float LANE_SWITCH_DURATION = 0.10f; public static final float RUNNER_ENTER_DURATION = 1.72f; private static final float RUNNER_STANDING_DURATION = 0.5f; private static final float SETUP_DURATION = RUNNER_ENTER_DURATION + RUNNER_STANDING_DURATION + 0.6f; // Speed constants. public static final float BASE_SPEED = 340f; private static final float PLAYER_MINIMUM_SPEED = BASE_SPEED; private static final float PLAYER_MAXIMUM_SPEED = BASE_SPEED + 450f; private static final float PLAYER_DECELERATION = 300f; private static final float MANGO_SPEED = BASE_SPEED + 250f; private static final float GRAPE_SPEED = BASE_SPEED + 315f; private static final float APRICOT_SPEED = BASE_SPEED + 280f; private static final float WATERMELON_MINIMUM_SPEED = PLAYER_MINIMUM_SPEED + 100f; private static final float WATERMELON_MAXIMUM_SPEED = PLAYER_MAXIMUM_SPEED + 25f; private static final float WATERMELON_SPEED_INCREASE_PER_SECOND = 2.5f; private static final float WATERMELON_SPEED_DECREASE_PER_SECOND = 6f; private static final float OPPONENT_SLOW_DOWN_DURATION = 0.24f; private static final float OPPONENT_SLOW_DOWN_AMOUNT = 450f; private static final float RUNNER_FINISH_DURATION = 5f; private static final float WATERMELON_FINISH_DURATION = 4f; // Size constants. private static final float STRAWBERRY_RADIUS = 15f; private static final float MANGO_RADIUS = 28f; private static final float GRAPE_RADIUS = 12f; private static final float APRICOT_RADIUS = 22f; // Misc. constants. private static final float POWER_UP_SPEED_BOOST = 320f; private static final float TOTAL_DISTANCE = 16000f; private static final int VIBRATION_MS = 60; // Positional constants. private static final float PLAYER_INITIAL_POSITION_Y = HEIGHT * 0.25f; private static final float OPPONENT_INITIAL_POSITION_Y = HEIGHT * 0.4f; private static final float RIBBON_INITIALIZATION_POSITION_Y = PLAYER_INITIAL_POSITION_Y + TOTAL_DISTANCE; // Camera constants. private static final float CAMERA_LOOKAHEAD_INCREASE_PER_SECOND = 0.8f; private static final float CAMERA_LOOKAHEAD_DECREASE_PER_SECOND = 2.5f; private static final float CAMERA_MAXIMUM_LOOKAHEAD = -(HEIGHT * 0.25f); // Gameplay variables. private float baseScale; private State state; private float laneSwitchTimer = 0f; private float cameraLookAhead; // The camera trails behind the player when player is going fast. private boolean watermelonHasEntered = false; private boolean playerHasStarted = false; // Player performance variables. private float time; private int finishingPlace; // First place = 1. // Listeners. private StateChangedListener stateListener; private ScoreListener scoreListener; // Public so view can render the actors. public final List<Actor> actors = Collections.synchronizedList(new ArrayList<Actor>()); public final List<Actor> ui = Collections.synchronizedList(new ArrayList<Actor>()); private final List<PowerUpActor> powerUps = new ArrayList<PowerUpActor>(); private final List<OpponentActor> opponents = new ArrayList<OpponentActor>(); public final CameraShake cameraShake; // Public so view can read the amount of shake. public final Camera camera; // Actors. public PlayerActor player; public OpponentActor mango; public OpponentActor grape; public OpponentActor apricot; public WatermelonActor watermelon; public RibbonActor ribbon; // UI Buttons. public FakeButtonActor leftButton; public FakeButtonActor rightButton; private RectangularInstructionActor instructions; private TextView countdownView; // Background public BackgroundActor backgroundActor; // public so that the GameFragment can update the duration. public long titleDurationMs = GameFragment.TITLE_DURATION_MS; public PursuitModel(Resources resources, Context context) { this.resources = resources; eventBus = EventBus.getInstance(); vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); locale = resources.getConfiguration().locale; cameraShake = new CameraShake(); camera = new Camera(WIDTH, HEIGHT); camera.position.x = -HALF_WIDTH; map = new PursuitLevel(R.raw.running, resources); initActors(); showTitle(); } private void initActors() { // Initialize background backgroundActor = new BackgroundActor(resources, camera); backgroundActor.scale = 1.15f; player = new PlayerActor(resources, INITIAL_LANE); player.setRadius(STRAWBERRY_RADIUS); ribbon = new RibbonActor(0, RIBBON_INITIALIZATION_POSITION_Y, resources); watermelon = new WatermelonActor(resources); // Scale actors to fit screen baseScale = (1f * EXPECTED_PLAYER_WIDTH) / player.currentSprite.frameWidth; player.scale = baseScale * 1.07f; watermelon.scale = baseScale * 1.6f; ribbon.scale = baseScale * 1.03f; // Add the essential actors to screen synchronized (actors) { actors.add(player); actors.add(ribbon); actors.add(watermelon); } // Add the opponents mango = addOpponent(RunnerType.MANGO, 0, OPPONENT_INITIAL_POSITION_Y, MANGO_RADIUS); grape = addOpponent(RunnerType.GRAPE, 1, OPPONENT_INITIAL_POSITION_Y, GRAPE_RADIUS); apricot = addOpponent(RunnerType.APRICOT, 2, OPPONENT_INITIAL_POSITION_Y, APRICOT_RADIUS); AnimatedSprite runningTutorialSprite = AnimatedSprite.fromFrames(resources, Sprites.tutorial_running); runningTutorialSprite.setFPS(7); // Initialize and add the UI actors instructions = new RectangularInstructionActor(resources, runningTutorialSprite); instructions.hidden = true; instructions.scale = 0.54f; instructions.position.set(HALF_WIDTH - instructions.getScaledWidth() / 2, HEIGHT * 0.65f - instructions.getScaledHeight() / 2); leftButton = new FakeButtonActor( AnimatedSprite.fromFrames(resources, Sprites.running_button)); leftButton.rotation = -(float) Math.PI / 2; leftButton.sprite.setFPS(12); leftButton.scale = BUTTON_SCALE; leftButton.position.x = WIDTH * 0.22f - (leftButton.sprite.frameWidth * BUTTON_SCALE / 2); leftButton.position.y = HEIGHT - 20; leftButton.alpha = 0; rightButton = new FakeButtonActor( AnimatedSprite.fromFrames(resources, Sprites.running_button)); rightButton.rotation = (float) Math.PI / 2; rightButton.sprite.setFPS(12); rightButton.scale = BUTTON_SCALE; rightButton.position.x = WIDTH * 0.78f + (rightButton.sprite.frameWidth * BUTTON_SCALE / 2); rightButton.position.y = HEIGHT - (rightButton.sprite.frameHeight * BUTTON_SCALE) - 20; rightButton.alpha = 0; ui.add(instructions); ui.add(leftButton); ui.add(rightButton); } // Resets the gameplay variables for a replay or a first play. private void resetGame() { Log.i(TAG, "Reset game."); camera.position.y = 0; laneSwitchTimer = 0; player.setRunnerState(RunnerState.CROUCH); player.setSweat(false); player.setCelebrate(false); mango.setRunnerState(RunnerState.CROUCH); mango.isFinished = false; grape.setRunnerState(RunnerState.CROUCH); grape.isFinished = false; apricot.setRunnerState(RunnerState.CROUCH); apricot.isFinished = false; tweenManager.removeAll(); watermelonHasEntered = false; playerHasStarted = false; cameraLookAhead = 0; watermelon.position.y = -HEIGHT * 0.5f; watermelon.velocity.y = 0; player.position.x = 0; player.position.y = PLAYER_INITIAL_POSITION_Y; player.velocity.y = 0; ribbon.position.y = RIBBON_INITIALIZATION_POSITION_Y; synchronized (actors) { for (PowerUpActor powerUp : powerUps) { actors.remove(powerUp); } } powerUps.clear(); mango.position.y = OPPONENT_INITIAL_POSITION_Y; grape.position.y = OPPONENT_INITIAL_POSITION_Y; apricot.position.y = OPPONENT_INITIAL_POSITION_Y; mango.velocity.y = 0; grape.velocity.y = 0; apricot.velocity.y = 0; leftButton.alpha = 1; rightButton.alpha = 1; powerUpTimer = 0; mapRow = 0; time = 0; finishingPlace = 0; updateScore(); eventBus.sendEvent(EventBus.PAUSE_SOUND, R.raw.running_foot_loop_fast); } private void beginGame() { mango.setRunnerState(RunnerState.RUNNING); grape.setRunnerState(RunnerState.RUNNING); apricot.setRunnerState(RunnerState.RUNNING); player.velocity.y = 0; watermelon.velocity.y = PLAYER_MAXIMUM_SPEED; tweenManager.add(new Tween(0.6f) { @Override protected void updateValues(float percentDone) { mango.velocity.y = percentDone * MANGO_SPEED; grape.velocity.y = percentDone * GRAPE_SPEED; apricot.velocity.y = percentDone * APRICOT_SPEED; } }); eventBus.sendEvent(EventBus.PLAY_SOUND, R.raw.running_foot_loop_fast); eventBus.sendEvent(EventBus.PLAY_SOUND, R.raw.running_foot_power_up); } public void update(float deltaMs) { synchronized (this) { float timeInSeconds = deltaMs / 1000f; camera.update(deltaMs); cameraShake.update(deltaMs); if (state == State.RUNNING) { if (!(player.getRunnerState() == RunnerState.RUNNING_LEFT || player.getRunnerState() == RunnerState.RUNNING_RIGHT)) { player.setRunnerState(RunnerState.RUNNING); } if (player.getRunnerState() == RunnerState.RUNNING) { updatePlayerSpeed(timeInSeconds); } } if (state != State.TITLE) { for (int i = 0; i < actors.size(); i++) { actors.get(i).update(deltaMs); } for (int i = 0; i < ui.size(); i++) { ui.get(i).update(deltaMs); } } tweenManager.update(deltaMs); synchronized (actors) { Collections.sort(actors); } laneSwitchTimer = Math.max(0, laneSwitchTimer - timeInSeconds); if (state == State.RUNNING || (state == State.SUCCESS && finishingPlace == 1)) { updateRunningCamera(timeInSeconds); } if (state == State.RUNNING) { // Reads the next row of power ups from PursuitLevel and adds them, // Then resets the timer. powerUpTimer -= timeInSeconds; if (powerUpTimer <= 0) { powerUpTimer = 0.18f - 0.06f * (Math.max(0, player.velocity.y) / PLAYER_MAXIMUM_SPEED); addPowerUpRow(); mapRow++; } checkPowerUps(); updateWatermelon(timeInSeconds); // If player is getting close to the watermelon, make the sprite sweat. if (player.position.y - player.getRadius() < watermelon.position.y + WatermelonActor.VERTICAL_RADIUS_WORLD * 2.7f) { player.setSweat(true); } else { player.setSweat(false); } // Check success and fail states based on player's location if (watermelonCollidesWithRunner(player)) { gameFail(); } else if (player.position.y > ribbon.position.y) { gameSuccess(); } // Update the score time += timeInSeconds; updateScore(); } checkOpponentsPlayerCollision(); checkOpponentsFinished(); checkOpponentsWatermelonCollision(); } } // If the state is running, update the camera according to the player's position and speed. // If the player is sufficiently fast, the camera trails behind the player a little. private void updateRunningCamera(float timeInSeconds) { float targetCameraLookahead = CAMERA_MAXIMUM_LOOKAHEAD * Math.max(0, player.velocity.y - PLAYER_MINIMUM_SPEED) / (PLAYER_MAXIMUM_SPEED - PLAYER_MINIMUM_SPEED); float deltaCameraLookahead = (targetCameraLookahead - cameraLookAhead); if (deltaCameraLookahead > 0) { deltaCameraLookahead *= CAMERA_LOOKAHEAD_DECREASE_PER_SECOND * timeInSeconds; } else { deltaCameraLookahead *= CAMERA_LOOKAHEAD_INCREASE_PER_SECOND * timeInSeconds; } cameraLookAhead += deltaCameraLookahead; camera.position.y = -PLAYER_INITIAL_POSITION_Y + cameraLookAhead + player.position.y; } // Decelerates the player. private void updatePlayerSpeed(float timeInSeconds) { if (!playerHasStarted) { player.velocity.y += timeInSeconds * PLAYER_MINIMUM_SPEED * 1.7f; if (player.velocity.y > PLAYER_MINIMUM_SPEED) { playerHasStarted = true; } } else { player.velocity.y = player.velocity.y - PLAYER_DECELERATION * timeInSeconds; // Give speed lower and upper bound player.velocity.y = Math.max(player.velocity.y, PLAYER_MINIMUM_SPEED); player.velocity.y = Math.min(player.velocity.y, PLAYER_MAXIMUM_SPEED); } } // Changes the watermelon's speed to roughly match the player's. private void updateWatermelon(float timeInSeconds) { float targetSpeed = BASE_SPEED + ((player.velocity.y - BASE_SPEED) * 1.08f); // Give speed lower and upper bound targetSpeed = Math.max(targetSpeed, WATERMELON_MINIMUM_SPEED); targetSpeed = Math.min(targetSpeed, WATERMELON_MAXIMUM_SPEED); float targetSpeedDelta = targetSpeed - watermelon.velocity.y; float watermelonPlayerDistance = (player.position.y - player.getRadius()) - (watermelon.position.y + WatermelonActor.VERTICAL_RADIUS_WORLD); if (targetSpeedDelta < 0 || !watermelonHasEntered) { // The watermelon decrease speed is inversely proportional to the distance between the // watermelon and the player. The closer the player the larger the decrease. float decreaseFactor = 1 + ((1 / Math.max(1, watermelonPlayerDistance / 40)) * 7f); float decreaseSpeed = WATERMELON_SPEED_DECREASE_PER_SECOND * decreaseFactor; watermelon.velocity.y += targetSpeedDelta * Math.min(1, decreaseSpeed * timeInSeconds); } else if (targetSpeedDelta > 0) { // The watermelon increase speed is proportional to the distance between the watermelon and // the player. The further the player the larger the increase. // The watermelon decrease speed is also affected by the amount of time played. The longer // the time the greater the increase. float increaseFactor = Math.max(0.6f, 1 + ((watermelonPlayerDistance - 140) / 240)); float timeFactor = 0.3f + (0.7f * Math.min(1, time / 20f)); float increaseSpeed = WATERMELON_SPEED_INCREASE_PER_SECOND * increaseFactor * timeFactor; watermelon.velocity.y += targetSpeedDelta * Math.min(1, increaseSpeed * timeInSeconds); } if (watermelon.position.y > camera.position.y) { watermelonHasEntered = true; } if (watermelonHasEntered) { watermelon.position.y = Math.max(watermelon.position.y, camera.position.y); watermelon.position.y = Math.min(watermelon.position.y, camera.position.y - CAMERA_MAXIMUM_LOOKAHEAD * 1.13f); } } private void updateScore() { scoreListener.newScore(time); } // Check power ups for collision with the player. private void checkPowerUps() { for (int i = powerUps.size() - 1; i >= 0; i--) { PowerUpActor powerUp = powerUps.get(i); // Check if PowerUp collides with the Player. double powerUpDistance = ActorHelper.distanceBetween(player, powerUp); if (powerUpDistance < (PowerUpActor.RADIUS_WORLD + player.getRadius()) && !powerUp.isPickedUp()) { powerUpPlayer(powerUp); } } } // Checks if any of the opponents collide with the player. // If there is a collision, set the player's position to the runner's. private void checkOpponentsPlayerCollision() { // If the player is currently moving just ignore any collision. if (laneSwitchTimer > 0) { return; } for (OpponentActor opponent : opponents) { if (opponent.getLane() == player.getLane()) { float diffY = player.position.y - opponent.position.y; float combinedRadius = opponent.getRadius() + player.getRadius(); if (0 <= diffY && diffY < combinedRadius) { // Player is in front of opponent opponent.position.y = player.position.y - combinedRadius; } else if (-combinedRadius < diffY && diffY < 0) { // Player is behind the opponent player.position.y = opponent.position.y - combinedRadius; } } } } // Checks if any of the opponents are touching the watermelon. If so, squish the runner. private void checkOpponentsWatermelonCollision() { for (OpponentActor opponent : opponents) { if (opponent.getRunnerState() == RunnerState.RUNNING && watermelonCollidesWithRunner(opponent)) { eventBus.sendEvent(EventBus.PLAY_SOUND, R.raw.running_foot_power_squish); opponent.setRunnerState(RunnerState.DYING); opponent.velocity.y = 0; } } } private void checkOpponentsFinished() { for (final OpponentActor opponent : opponents) { if (opponent.position.y > ribbon.position.y) { // The opponent crosses the finish line, break the ribbon and decelerate the opponent. if (!opponent.isFinished) { opponent.isFinished = true; final float initialSpeed = opponent.velocity.y; tweenManager.add(new Tween(RUNNER_FINISH_DURATION) { @Override protected void updateValues(float percentDone) { if (opponent.state == RunnerState.RUNNING) { opponent.velocity.y = (1 - percentDone) * initialSpeed; if (percentDone > 0.95f) { opponent.setRunnerState(RunnerState.STANDING); } } else { opponent.velocity.y = 0; } } }); } } } } // TODO: Replace with an ellipse to circle collision model. public boolean watermelonCollidesWithRunner(RunnerActor actor) { if (actor.position.y - actor.getRadius() < watermelon.position.y + WatermelonActor.VERTICAL_RADIUS_WORLD && actor.getLane() != INITIAL_LANE) { return true; } return actor.position.y - actor.getRadius() < watermelon.position.y + WatermelonActor.VERTICAL_RADIUS_WORLD * 1.2f && actor.getLane() == INITIAL_LANE; } private void gameSetup() { Log.i(TAG, "Game Mode: Setup."); setState(State.SETUP); player.position.y = PLAYER_INITIAL_POSITION_Y; mango.position.y = OPPONENT_INITIAL_POSITION_Y; grape.position.y = OPPONENT_INITIAL_POSITION_Y; apricot.position.y = OPPONENT_INITIAL_POSITION_Y; mango.setRunnerState(RunnerState.STANDING); grape.setRunnerState(RunnerState.STANDING); apricot.setRunnerState(RunnerState.STANDING); final Tween mangoCrouchDelay = new EmptyTween(0.6f) { @Override protected void onFinish() { mango.setRunnerState(RunnerState.CROUCH); } }; tweenManager.add(mangoCrouchDelay); final Tween grapeCrouchDelay = new EmptyTween(0.9f) { @Override protected void onFinish() { grape.setRunnerState(RunnerState.CROUCH); } }; tweenManager.add(grapeCrouchDelay); final Tween apricotCrouchDelay = new EmptyTween(1.2f) { @Override protected void onFinish() { apricot.setRunnerState(RunnerState.CROUCH); } }; tweenManager.add(apricotCrouchDelay); runnerEnter(player); watermelon.position.y = HEIGHT * -0.5f; final Tween setupDuration = new EmptyTween(SETUP_DURATION) { @Override protected void onFinish() { if (state == State.SETUP) { gameFirstPlay(); } } }; tweenManager.add(setupDuration); } private void gameFirstPlay() { Log.i(TAG, "Game Mode: Tutorial."); instructions.show(); tweenManager.add(new EmptyTween(TUTORIAL_DURATION) { @Override protected void onFinish() { instructions.hide(); } }); tweenManager.add(new EmptyTween(TUTORIAL_DURATION + 0.3f) { @Override protected void onFinish() { startCountdownAnimation(); } }); tweenManager.add(new EmptyTween(TUTORIAL_DURATION + 0.3f + 3f) { @Override protected void onFinish() { gameStart(); countdownView.post(new Runnable() { @Override public void run() { countdownView.setVisibility(View.INVISIBLE); } }); } }); player.setLane(INITIAL_LANE); showButtons(); setState(State.READY); } public void gameReplay() { Log.i(TAG, "Game Restart."); resetGame(); instructions.hide(); // In case the player hits the replay button before the game begins. tweenManager.add(new EmptyTween(0.2f) { @Override protected void onFinish() { startCountdownAnimation(); } }); tweenManager.add(new EmptyTween(0.2f + 3f) { @Override protected void onFinish() { gameStart(); countdownView.post(new Runnable() { @Override public void run() { countdownView.setVisibility(View.INVISIBLE); } }); } }); player.setLane(INITIAL_LANE); setState(State.READY); } private void gameStart() { Log.i(TAG, "Game Start."); beginGame(); setState(State.RUNNING); } private void gameSuccess() { Log.i(TAG, "You're winner!"); // Make the watermelon match the player's speed so the player won't get squished. hideButtons(); int runnersBehindPlayer = 0; for (RunnerActor opponent : opponents) { if (player.position.y >= opponent.position.y) { runnersBehindPlayer++; } } finishingPlace = 4 - runnersBehindPlayer; if (finishingPlace == 1) { // If the player gets first place, stop the watermelon and decelerate the player. // Play the strawberry's celebration animation. player.setCelebrate(true); final float currentSpeed = player.velocity.y; final float targetSpeed = BASE_SPEED * 0.75f; tweenManager.add(new Tween(RUNNER_FINISH_DURATION) { @Override protected void updateValues(float percentDone) { float diffSpeed = targetSpeed - currentSpeed; player.velocity.y = currentSpeed + (diffSpeed * percentDone); } }); tweenManager.add(new Tween(WATERMELON_FINISH_DURATION) { @Override protected void updateValues(float percentDone) { watermelon.velocity.y = currentSpeed * (1 - percentDone); } }); } else { // If the player does not get first place, stop both the watermelon and the player. // If the player finishes fourth, make the strawberry cry. if (finishingPlace == 4) { player.setSweat(true); } final float currentSpeed = player.velocity.y; tweenManager.add(new Tween(WATERMELON_FINISH_DURATION * 0.5f) { @Override protected void updateValues(float percentDone) { watermelon.velocity.y = currentSpeed * (1 - percentDone); } }); tweenManager.add(new Tween(RUNNER_FINISH_DURATION) { @Override protected void updateValues(float percentDone) { if (player.getRunnerState() == RunnerState.RUNNING) { player.velocity.y = currentSpeed * (1 - percentDone); if (percentDone > 0.95f) { player.setRunnerState(RunnerState.STANDING); player.velocity.y = 0; } } } }); } eventBus.sendEvent(EventBus.PLAY_SOUND, R.raw.bmx_cheering); eventBus.sendEvent(EventBus.PAUSE_SOUND, R.raw.running_foot_loop_fast); setState(State.SUCCESS); } private void gameFail() { // Change the player's appearance. player.setRunnerState(RunnerState.DYING); // Stops the player. player.velocity.y = 0; hideButtons(); // Zoom the camera on the player. zoomCameraTo(-HALF_WIDTH, player.position.y - HEIGHT * 0.75f, camera.scale, 1.5f); eventBus.sendEvent(EventBus.PLAY_SOUND, R.raw.bmx_cheering); eventBus.sendEvent(EventBus.PAUSE_SOUND, R.raw.running_foot_loop_fast); vibrator.vibrate(VIBRATION_MS); setState(State.FAIL); } private void showButtons() { tweenManager.add(new ActorTween(leftButton).withAlpha(1).withDuration(0.5f)); tweenManager.add(new ActorTween(rightButton).withAlpha(1).withDuration(0.5f)); } private void hideButtons() { tweenManager.add(new ActorTween(leftButton).withAlpha(0).withDuration(0.5f)); tweenManager.add(new ActorTween(rightButton).withAlpha(0).withDuration(0.5f)); } private void startCountdownAnimation() { countdownView.post(new Runnable() { @Override public void run() { countdownView.setVisibility(View.VISIBLE); } }); final NumberFormat numberFormatter = NumberFormat.getInstance(locale); countdownBump(numberFormatter.format(3)); tweenManager.add(new EmptyTween(1) { @Override protected void onFinish() { countdownBump(numberFormatter.format(2)); } }); tweenManager.add(new EmptyTween(2) { @Override protected void onFinish() { countdownBump(numberFormatter.format(1)); } }); } private void countdownBump(final String text) { countdownView.post(new Runnable() { @Override public void run() { float countdownStartScale = countdownView.getScaleX() * 1.25f; float countdownEndScale = countdownView.getScaleX(); countdownView.setVisibility(View.VISIBLE); countdownView.setText(text); if (!"Nexus 9".equals(Build.MODEL)) { ValueAnimator scaleAnimation = UIUtil.animator(200, new AccelerateDecelerateInterpolator(), new AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { float scaleValue = (float) valueAnimator.getAnimatedValue("scale"); countdownView.setScaleX(scaleValue); countdownView.setScaleY(scaleValue); } }, UIUtil.floatValue("scale", countdownStartScale, countdownEndScale) ); scaleAnimation.start(); } } }); } // Make a row of power ups using the current row from PursuitLevel. private void addPowerUpRow() { char[] row = map.getRowArray(mapRow); float rowPositionY = player.position.y + HEIGHT; for (int i = 0; i < row.length; i++) { if (row[i] == '1') { if (ribbon.position.y > rowPositionY) { addPowerUp(getLanePositionX(i), rowPositionY); } } } } private PowerUpActor addPowerUp(float x, float y) { PowerUpActor powerUp = new PowerUpActor(x, y, resources); powerUp.scale = baseScale; synchronized (actors) { actors.add(powerUp); } powerUps.add(powerUp); return powerUp; } // Creates and adds a OpponentActor to the list of all actors. private OpponentActor addOpponent(RunnerType type, int lane, float y, float radius) { OpponentActor opponent = makeOpponent(type, lane, y, radius); synchronized (actors) { actors.add(opponent); } opponents.add(opponent); return opponent; } // Creates a OpponentActor. private OpponentActor makeOpponent(RunnerType type, int lane, float y, float radius) { OpponentActor opponent = new OpponentActor(resources, type, lane); opponent.position.x = getLanePositionX(lane); opponent.position.y = y; opponent.scale = baseScale; opponent.setRadius(radius); return opponent; } // Wait for one second for the title to display and then starts the game. private void showTitle() { setState(State.TITLE); tweenManager.add(new EmptyTween(titleDurationMs / 1000.0f) { @Override protected void onFinish() { if (state == State.TITLE) { gameSetup(); } } }); } // Handles all incoming motion events. Currently just calls down() and up(). public void touch(MotionEvent event) { final int action = event.getActionMasked(); int index = event.getActionIndex(); Point screenSize = AndroidUtils.getScreenSize(); float touchX = (event.getX(index) / screenSize.x) * PursuitModel.WIDTH; float touchY = (event.getY(index) / screenSize.y) * PursuitModel.HEIGHT; Vector2D worldPos = camera.getWorldCoords(touchX, touchY); if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN) { down(worldPos.x, worldPos.y); } } // Called every time the user touches down on the screen. // Currently does not support multi-touch. private void down(float touchX, float touchY) { Log.i(TAG, "Touch down at: " + touchX + ", " + touchY); if (state == State.RUNNING || state == State.READY) { int newLane = getLaneNumberFromPositionX(touchX); if (newLane > player.getLane() && player.getLane() < MAX_LANE) { rightButton.press(); movePlayer(player.getLane() + 1); } else if (newLane < player.getLane() && player.getLane() > MIN_LANE) { leftButton.press(); movePlayer(player.getLane() - 1); } } } private void movePlayer(final int newLane) { // The player cannot move if it is already moving. if (laneSwitchTimer > 0) { return; } // If an opponent and the player are expected to collide after the lane change, the player does // not move. // If the player is slightly in front of an opponent after the lane change, the player moves and // the opponent moves back slightly. for (RunnerActor opponent : opponents) { if (opponent.getLane() == newLane) { float targetOpponentY = opponent.position.y + opponent.velocity.y * LANE_SWITCH_DURATION; float targetPlayerY = player.position.y + (player.velocity.y * LANE_SWITCH_DURATION * 0.8f); float yDistance = targetOpponentY - targetPlayerY; float combinedRadius = opponent.getRadius() + player.getRadius(); if (0.2f * combinedRadius < yDistance && yDistance < combinedRadius) { Log.i(TAG, "Cannot switch to lane " + newLane); eventBus.sendEvent(EventBus.PLAY_SOUND, R.raw.present_throw_block); bumpPlayer(opponent); return; } else if (-1.2f * combinedRadius < yDistance && yDistance <= 0.2f * combinedRadius) { Log.i(TAG, "Switching to lane " + newLane + " with slowdown"); slowDownOpponent(opponent); } } } // Make the tween if (state == State.RUNNING) { tweenManager.add(new ActorTween(player) .toX(getLanePositionX(newLane)) .withDuration(LANE_SWITCH_DURATION) .withInterpolator(Interpolator.LINEAR)); laneSwitchTimer = LANE_SWITCH_DURATION; } else { // If the player is not currently running. Play the running left and right animation when // switching lanes. if (player.lane > newLane) { player.setRunnerState(RunnerState.RUNNING_LEFT); } else { player.setRunnerState(RunnerState.RUNNING_RIGHT); } tweenManager.add(new ActorTween(player) .toX(getLanePositionX(newLane)) .withDuration(LANE_SWITCH_DURATION * 2f) .withInterpolator(Interpolator.LINEAR) .whenFinished(new Callback() { @Override public void call() { if (player.getRunnerState() == RunnerState.RUNNING_LEFT || player.getRunnerState() == RunnerState.RUNNING_RIGHT) { player.setRunnerState(RunnerState.CROUCH); } else if (player.getRunnerState() == RunnerState.CROUCH) { player.setRunnerState(RunnerState.RUNNING); } } })); laneSwitchTimer = LANE_SWITCH_DURATION * 2f; } player.setLane(newLane); eventBus.sendEvent(EventBus.PLAY_SOUND, R.raw.jumping_jump); } // Temporarily reduces the speed of an opponent so the player can go in front of it when changing // lanes. private void slowDownOpponent(final RunnerActor opponent) { final float initialSpeed = opponent.velocity.y; float targetOpponentY = opponent.position.y + opponent.velocity.y * LANE_SWITCH_DURATION; float targetPlayerY = player.position.y + (player.velocity.y * LANE_SWITCH_DURATION * 0.8f); float diffY = targetOpponentY - targetPlayerY; float combinedRadius = opponent.getRadius() + player.getRadius(); // The further the opponent is head of the opponent, the greater the decrease in speed. final float slowDownAmount = OPPONENT_SLOW_DOWN_AMOUNT + (OPPONENT_SLOW_DOWN_AMOUNT * Math.max(-0.2f, (diffY / combinedRadius))); tweenManager.add(new Tween(OPPONENT_SLOW_DOWN_DURATION) { @Override protected void updateValues(float percentDone) { if (opponent.getRunnerState() == RunnerState.RUNNING) { float percentageSlowdown = 1 - percentDone; float speed = initialSpeed - (slowDownAmount * percentageSlowdown); opponent.velocity.y = speed; } else { opponent.velocity.y = 0; } } }); } private void bumpPlayer(RunnerActor opponent) { float targetX = player.position.x + (opponent.position.x - player.position.x) * 0.25f; float endX = player.position.x; final ActorTween outTween = new ActorTween(player) .toX(endX) .withDuration(LANE_SWITCH_DURATION * 0.5f) .withInterpolator(Interpolator.EASE_IN_AND_OUT); final ActorTween inTween = new ActorTween(player) .toX(targetX) .withDuration(LANE_SWITCH_DURATION * 0.5f) .withInterpolator(Interpolator.EASE_IN_AND_OUT) .whenFinished(new Callback() { @Override public void call() { tweenManager.add(outTween); } }); laneSwitchTimer = LANE_SWITCH_DURATION; tweenManager.add(inTween); } private ActorTween runnerEnter(final RunnerActor runner) { // Enter from the left. runner.setRunnerState(RunnerState.ENTERING); eventBus.sendEvent(EventBus.PLAY_SOUND, R.raw.present_throw_character_appear); // Stop and stand for 0.5 seconds. final Tween standingDelay = new EmptyTween(RUNNER_STANDING_DURATION) { @Override protected void onFinish() { runner.setRunnerState(RunnerState.CROUCH); } }; ActorTween tween = new ActorTween(runner) .withDuration(RUNNER_ENTER_DURATION) .withInterpolator(Interpolator.LINEAR) .whenFinished(new Callback() { @Override public void call() { // Crouch down after standing still. runner.setRunnerState(RunnerState.STANDING); eventBus.sendEvent(EventBus.PAUSE_SOUND, R.raw.present_throw_character_appear); tweenManager.add(standingDelay); } }); tweenManager.add(tween); return tween; } // Returns the world x of a lane. private int getLanePositionX(int lane) { return LANE_SIZE * (lane - INITIAL_LANE); } // Return the lane number corresponding to an area of the screen. // Warning: This function may return lane numbers that don't exist. Example: By clicking on the // area that is immediately left of lane 0, -1 is returned. private int getLaneNumberFromPositionX(float x) { int lane = INITIAL_LANE + Math.round(x / LANE_SIZE); Log.i(TAG, "Lane: " + lane); return lane; } // Called when the player picks up a power up. private void powerUpPlayer(PowerUpActor powerUpActor) { powerUpActor.pickUp(); player.velocity.y += POWER_UP_SPEED_BOOST; eventBus.sendEvent(EventBus.PLAY_SOUND, R.raw.running_foot_power_up_fast); } public void setState(State newState) { Log.i(TAG, "State changed to " + newState); state = newState; if (stateListener != null) { stateListener.onStateChanged(newState); } } private void zoomCameraTo(float x, float y, float scale, float secondsDuration) { final float x1 = camera.position.x; final float y1 = camera.position.y; final float camScale1 = camera.scale; // Limit (x, y) so that the edge of the camera doesn't go past the normal edges (i.e. the edges // at scale==1). final float x2 = x; final float y2 = y; final float camScale2 = scale; tweenManager.add(new Tween(secondsDuration) { @Override protected void updateValues(float percentDone) { Interpolator interp = Interpolator.EASE_IN_AND_OUT; camera.position.x = interp.getValue(percentDone, x1, x2); camera.position.y = interp.getValue(percentDone, y1, y2); // Tween the height, then use that to calculate scale, because tweening scale // directly leads to a wobbly pan (scale isn't linear). float height1 = HEIGHT / camScale1; float height2 = HEIGHT / camScale2; camera.scale = HEIGHT / interp.getValue(percentDone, height1, height2); } }); } public void setStateListener(StateChangedListener stateListener) { this.stateListener = stateListener; } public void setScoreListener(ScoreListener listener) { this.scoreListener = listener; updateScore(); } public void setCountdownView(TextView countdownView) { this.countdownView = countdownView; } public float getScore() { return time; // Milliseconds } public int getFinishingPlace() { return finishingPlace; } }