MazeCanvas.java

/**
 * Copyright (c) 2012-2013 Nokia Corporation. All rights reserved.
 * Nokia and Nokia Connecting People are registered trademarks of Nokia Corporation.
 * Oracle and Java are trademarks or registered trademarks of Oracle and/or its
 * affiliates. Other product and company names mentioned herein may be trademarks
 * or trade names of their respective owners.
 * See LICENSE.TXT for license information.
 */

package com.nokia.example.amaze.ui;

import javax.microedition.lcdui.Command;
import javax.microedition.lcdui.CommandListener;
import javax.microedition.lcdui.Displayable;
import javax.microedition.lcdui.Font;
import javax.microedition.lcdui.Graphics;
import javax.microedition.lcdui.game.GameCanvas;
import javax.microedition.m3g.Appearance;
import javax.microedition.m3g.Background;
import javax.microedition.m3g.Camera;
import javax.microedition.m3g.Graphics3D;
import javax.microedition.m3g.Mesh;
import javax.microedition.m3g.Node;
import javax.microedition.m3g.RayIntersection;
import javax.microedition.m3g.Transform;
import javax.microedition.m3g.World;

import com.nokia.mid.ui.DeviceControl;
import com.nokia.mid.ui.orientation.Orientation;
import com.nokia.mid.ui.orientation.OrientationListener;

import com.nokia.example.amaze.Main;
import com.nokia.example.amaze.gestures.DoubleTapDetector;
import com.nokia.example.amaze.gestures.SafeGestureEvent;
import com.nokia.example.amaze.gestures.SafeGestureInteractiveZone;
import com.nokia.example.amaze.gestures.SafeGestureListener;
import com.nokia.example.amaze.gestures.SafeGestureRegistrationManager;
import com.nokia.example.amaze.model.MarbleModel;
import com.nokia.example.amaze.model.Maze;
import com.nokia.example.amaze.model.MyTimer;
import com.nokia.example.amaze.sensors.AccelerationProvider;

/**
 * The main UI class.
 */
public class MazeCanvas
    extends GameCanvas
    implements Runnable,
               CommandListener,
               OrientationListener,
               CameraAnimator.Listener,
               AccelerationProvider.Listener,
               DoubleTapDetector.Listener,
               SafeGestureListener,
               Menu.Listener
{
    // Constants
    
    /* Ticks coefficient is used to define the normal speed of the game
     * regardless of the performance of the phone i.e. it is used to normalise
     * the changes to the model. In this case it can be understood as the
     * desired frame rate (or the duration of a single frame to be more
     * precise).
     */
    public static final float TICKS_COEFFICIENT = 1000.0f / 30.0f;
    
    private static final int MILLIS_TO_SLEEP = 5; // Milliseconds to wait in the main loop
    
    public static final int MAZE_CORRIDOR_COUNT = 10;
    public static final float MAZE_SIDE_LENGTH = 200f; // Defines the overall size of the maze
    private static final float WALL_HEIGHT = 10.f; // Height of the walls
    private static final int POSITION_TOLERANCE = 12;
    private static final float DEFAULT_STEP_LENGTH_PER_AXIS = 3.0f; // Amount to advance on each step
    
    public static final float ACCELERATION_THRESHOLD = 1.5f;
    public static final float ACCELERATION_COEFFICIENT_X = 1.5f;
    public static final float ACCELERATION_COEFFICIENT_Y = 0.3f;
    private static final int MIN_CALIBRATION_VALUE = -6;
    public static final int UNDEFINED = -20;
    private static final int ZOOM_INTERVAL = 10;
    
    private static final float DEFAULT_TOP_CAMERA_ANGLE = 75f;
    private static final float PAUSE_CAMERA_ANGLE = 45f;
    private static final float DEFAULT_ZOOM = 1.0f;
    private static final int MIN_ZOOM = -70; // As close as you can zoom
    private static final int MAX_ZOOM = -MIN_ZOOM; // As far as you can zoom
    private static final float MAX_TILT_COEFFICIENT = 3.0f;
    private static final float TILT_THRESHOLD = 0.1f;
    private static final int LEVEL_TIME = 60; // Initial level time, in seconds
    private static final int MIN_FPS_STATS_COUNT = 20;
    
    // Game states
    public static final int NOT_STARTED = 0; // When in start menu
    public static final int TRANSITION_ANIMATION_ONGOING = 1;
    public static final int ONGOING = 2;
    public static final int PAUSED = 3;
    public static final int LEVEL_FINISHED = 4;
    public static final int GAME_OVER = 5;
    
    private static final String RESUME_MENU_ITEM_TEXT = "Resume";
    private static final String SET_BACKGROUND_PREFIX_MENU_ITEM_TEXT = "Set background ";
    private static final String SWITCH_DEBUG_MODE_PREFIX_MENU_ITEM_TEXT = "Switch debug mode ";
    private static final String RESTART_GAME_MENU_ITEM_TEXT = "Restart game";
    
    public static final int TEXT_COLOR = 0xff81daff;
    public static final int MARGIN = 5;

    // Members
    private final Main _midlet;
    private WorldBuilder _builder;
    private Menu _menu;
    private final MenuItem _blinkingMenuItem = new MenuItem("Tap screen to start game");
    private Thread _mainThread = null;
    private Maze _maze = null;
    private final MarbleModel _marbleModel;
    private Graphics3D _graphics3d; // Graphics 3D global instance
    private World _world;
    private Camera _topCamera = null;
    private Camera _povCamera = null;    
    private Background _background = null;
    private Graphics _graphics = null;
    private CameraAnimator _cameraAnimator = null;
    private Appearance _wallClearAppearance = new Appearance();
    private Appearance _wallAppearance = new Appearance();
    private Mesh _marble = null; // Simple mesh representing the marble
    private IconButton[] _iconButtons = new IconButton[4];
    private InfoDialog _infoDialog = null;
    private DoubleTapDetector _doubleTapDetector = null;
    private final Command _exitAndBackCommand = new Command("", Command.EXIT, 1);
    private double _calibration = UNDEFINED;
    private double _ax;
    private double _ay;
    private boolean _goalReached = false;
    private float _zoom = DEFAULT_ZOOM;
    private float _previousZoom = MIN_ZOOM;
    private float _relativeZoom = 0.5f;
    private float _cameraOffset = 1;
    private float _povAngleY = 0.0f;
    private boolean _povMode = true;
    private float _prevTiltX = 0f;
    private float _prevTiltY = 0f;    
    private volatile boolean _running = true; // Flag to keep the drawing going
    private int _gameState = NOT_STARTED;
    private volatile int _loopCounter = 1;
    private volatile float _fps = 0;
    private float _averageFps = 0;
    private int _fpsCount = 0; // For calculating the average
    private volatile long _levelStartTime = 0;
    private volatile long _timeUsed = 0;
    private volatile long _timeToBeat = 0;
    private int _level = 1;
    private int _width = 0;
    private int _height = 0;
    private boolean _isPortrait = true;
    private boolean _hasBackground = true;
    private boolean _firstTime = true;
    public boolean _debugMode = false;    

    /**
     * Constructor.
     * @param _midlet The parent MIDlet.
     */
    public MazeCanvas(Main midlet) {
        super(true);
        _midlet = midlet;
        _marbleModel = new MarbleModel();
        setCommandListener(this);
    }

    /**
     * Initializes the game canvas.
     */
    public void init() {
        setFullScreenMode(true);
        
        _width = getWidth();
        _height = getHeight();
        
        _maze = new Maze(MAZE_CORRIDOR_COUNT, MAZE_SIDE_LENGTH, WALL_HEIGHT);
        _marbleModel.setPosition(0f, 2 * WALL_HEIGHT + 3f, 0f);
        
        _doubleTapDetector = new DoubleTapDetector(this);
        
        // Register to listen to the pinch event
        // Note: If the device doesn't have multitouch support, this will cause
        // unhandled exception.
        SafeGestureRegistrationManager.setListener(this, this);
        SafeGestureInteractiveZone gestureZone = new SafeGestureInteractiveZone();
        gestureZone.setGesture(SafeGestureInteractiveZone.GESTURE_PINCH);
        gestureZone.setRectangle(0, 0, _width, _height);
        SafeGestureRegistrationManager.register(this, gestureZone);
        
        _topCamera = new Camera();
        _povCamera = new Camera();
        
        _menu = new Menu(this, _width, _height);
        
        // Get the Graphics3D instance
        _graphics3d = Graphics3D.getInstance();
        
        // Create buttons
        if (!Main.HAS_ONE_KEY_BACK) {
            _iconButtons[IconButton.EXIT] = new IconButton(IconButton.EXIT);
        }
        
        _iconButtons[IconButton.INFO] = new IconButton(IconButton.INFO);
        _iconButtons[IconButton.PAUSE] = new IconButton(IconButton.PAUSE);
        _iconButtons[IconButton.VIEW_MODE] = new IconButton(IconButton.VIEW_MODE);
        
        // Create the world and the maze
        _world = new World();
        _world.addChild(_topCamera);
        _world.addChild(_povCamera);
        _world.setActiveCamera(_topCamera);
        
        _builder = new WorldBuilder();
        _background = _builder.createBackground(_world);
        _builder.createFloor(_world);        
        _builder.createWallAppearances(_wallAppearance, _wallClearAppearance);
        _marble = _builder.createMarble(_world);
        
        _povCamera.setPerspective(60.f,
                (float)getWidth() / (float)getHeight(),
                0.1f, 1000.f);
        _povMode = false;        
        _povAngleY = -180f;
        
        calculateRelativeZoomAndCameraOffset();
        
        _cameraAnimator = new CameraAnimator(this, _world, _topCamera);
        
        createNewLevel();    
        
        setRunning(true);
        
        AccelerationProvider.getProvider(this);
        Orientation.addOrientationListener(this);
        
        showStartMenu();
    }

    /**
     * This method implements main (game) loop.
     * @see java.lang.Runnable#run()
     */
    public void run() {
        _graphics = getGraphics();
        long tempTime = 0;
        long ticks = 0;
        
        while (_running) {
            long time = System.currentTimeMillis();
            
            if (isShown()) {
                MyTimer.instance().update(time);
                
                if (_gameState == ONGOING) {
                    _timeUsed = time - _levelStartTime;
                    
                    if (_timeToBeat - _timeUsed <= 0) {
                        // Out of time! End the game.
                        endGame();
                    }
                    
                    updateModel((int)ticks);
                    updateCamera();
                    
                    if (time - tempTime > 5000) {
                        DeviceControl.setLights(0, 100); // Prevent screen lock
                        tempTime = time;
                    }
                } // if (_gameState == ONGOING)
                
                // Camera animator is updated all the time if running
                if (_cameraAnimator.running()) {
                    _cameraAnimator.update((int)ticks);
                    
                    if (_cameraAnimator.animationType() ==
                            CameraAnimator.PAUSE_ANIMATION
                            && time - tempTime > 20)
                    {
                        shiftBackground(-1);
                        tempTime = time;
                    }
                }
                
                // Tilting the board does not need to be done very often
                if (_loopCounter % 3 == 0) {
                    tiltBoard();
                }
                
                // Update the loop counter
                if (_loopCounter > 9) {
                    _loopCounter = 1;
                }
                else {
                    _loopCounter++;
                }
                
                // Draw the graphics
                draw3D(_graphics, (int)ticks);
                draw2D(_graphics);
                                
                // Flush the buffer to the screen
                flushGraphics();
            } // if (isShown())
            
            // Wait for a little and give the other threads the chance to run
            try {
                Thread.sleep(MILLIS_TO_SLEEP);
            }
            catch (InterruptedException e) {
            }
            
            ticks = System.currentTimeMillis() - time;
            
            if (isShown() && (_debugMode || _fpsCount < MIN_FPS_STATS_COUNT)) {
                // Calculate the FPS
                _fps = ticks > 0 ? 1000 / ticks : 60;
                
                if (_fpsCount < MIN_FPS_STATS_COUNT) {
                    _averageFps += _fps;
                    _fpsCount++;
                    
                    if (_fpsCount == MIN_FPS_STATS_COUNT) {
                        // Enough figures calculated. Calculate now the average.
                        _averageFps /= _fpsCount;
                        
                        if (_firstTime && _averageFps > 1) {
                            if (_averageFps < 20) {
                                // Remove the background to boost the performance
                                setBackground(false);
                            }
                            
                            _firstTime = false;
                        }
                    }
                }
            } // if (_debugMode || _fpsCount < MIN_FPS_STATS_COUNT)
        } // while (_running)
    }

    /**
     * Updates the model i.e. calculates the new position of the marble.
     */
    private final void updateModel(final int ticks) {
        if (_povMode) {
            moveMarbleInPovMode();
        }
        else {
            // Update the marble position
            float[] position = _marbleModel.position();
            _marble.setTranslation(position[0] + MarbleModel.DEFAULT_SIZE,
                                   position[1],
                                   position[2] + MarbleModel.DEFAULT_SIZE);
            moveMarble(ticks);
        }
        
        // This does not need to be done very so often
        if (_loopCounter == 9) {
            // Check if the marble reached the end
            if (!_goalReached) {
                final float[] position = _marbleModel.position();
                
                if (_maze.isAtTheEnd(position[0], position[2], POSITION_TOLERANCE)) {
                    // We're there!
                    _goalReached = true;
                    finishLevel();
                }
            }
        }
    }

    //-------------------------------------------------------------------------
    // UI interaction implementation ->
    //-------------------------------------------------------------------------

    /**
     * @see javax.microedition.lcdui.Canvas#pointerPressed(int, int)
     */
    protected void pointerPressed(int x, int y) {
        if (_infoDialog != null) {
            return;
        }
        
        if (TipBox.visible()) {
            TipBox.hide();
            return;
        }
        
        if (_gameState == MazeCanvas.NOT_STARTED
                || _gameState == MazeCanvas.PAUSED
                || _gameState == MazeCanvas.GAME_OVER)
        {
            if (!Main.HAS_ONE_KEY_BACK && x > _width - 42 && y > _height - 42) {
                // Exit button pressed
                setButtonPressed(IconButton.EXIT);
            }
            else if (x < 42 && y > _height - 42) {
                // Info button pressed
                setButtonPressed(IconButton.INFO);
            }
            else {
                switch (_gameState) {
                case NOT_STARTED: {
                    startNewGame();
                    break;
                }
                case PAUSED: {
                    _menu.onPressed(x, y);
                    break;
                }
                case GAME_OVER: {
                    _cameraAnimator.startAnimation(
                        CameraAnimator.LEVEL_RESET_ANIMATION_STEP);
                    break;
                }
                }
            }
            
            return;
        }
        
        if (_doubleTapDetector.onTapped(x, y)) {
            // Double tap was detected
            return;
        }
        
        if (x > _width - 42 && y > _height - 42) {
            // Pause button pressed
            setButtonPressed(IconButton.PAUSE);
        }
        else if (x < 42 && y > _height - 42) {
            // View mode button pressed
            // Do nothing yet
        }
    }

    /**
     * @see javax.microedition.lcdui.Canvas#pointerReleased(int, int)
     */
    protected void pointerReleased(int x, int y) {
        if (_gameState == NOT_STARTED
                || _gameState == PAUSED
                || _gameState == GAME_OVER)
        {
            if (_infoDialog != null) {
                // Info dialog is visible
                if (y > _infoDialog.linkTextY() - MARGIN
                    && y < _infoDialog.linkTextY()
                        + _infoDialog.linkTextHeight() + MARGIN)
                {
                    Main.openLink(InfoDialog.LINK_URL);
                }
                else {
                    _infoDialog = null;
                    
                    if (Main.HAS_ONE_KEY_BACK && _gameState == PAUSED) {
                        removeCommand(_exitAndBackCommand);
                    }
                }
                
                return;
            }
            
            if (!Main.HAS_ONE_KEY_BACK
                && _iconButtons[IconButton.EXIT].pressed()
                && x > _width - 42 && y > _height - 42)
            {
                // Exit button was tapped, do quit
                _midlet.quitApp();
            }
            else if (_iconButtons[IconButton.INFO].pressed()
                     && x < 42 && y > _height - 42)
            {
                // Show info dialog
                if (_infoDialog == null) {
                    _infoDialog = new InfoDialog(_midlet);
                    
                    if (Main.HAS_ONE_KEY_BACK) {
                        addCommand(_exitAndBackCommand);
                    }
                }
            }
            else {
                _menu.onReleased(x, y);
            }
            
            setButtonPressed(UNDEFINED);
            return;
        }
        
        if (_gameState == ONGOING) {
            if (_iconButtons[IconButton.PAUSE].pressed()
                && x > _width - 42 && y > _height - 42)
            {
                // Do pause
                pause();
            }
            else if (x < 42 && y > _height - 42)
            {
                // Toggle view mode
                toggleViewMode();
            }
        }
        
        setButtonPressed(UNDEFINED);
    }

    /**
     * @see javax.microedition.lcdui.Canvas#pointerDragged(int, int)
     */
    protected void pointerDragged(int x, int y) {
        if (_gameState == PAUSED && _infoDialog != null) {
            _menu.onPressed(x, y);
        }
    }

    /**
     * @see AccelerationProvider.Listener#onDataReceived(double, double, double)
     */
    public void onDataReceived(double ax, double ay, double az) {
        if (_gameState != ONGOING) {
            return;
        }
        
        if (_calibration == UNDEFINED) {
            // Do calibrate now based on the current reading
            if (_isPortrait) {
                setCalibration(-ay);
            }
            else {
                setCalibration(-ax);
            }
        }
        
        if (_isPortrait) {
            _ax = ax;
            _ay = ay + _calibration;
        }
        else {
            _ax = ax + _calibration;
            _ay = ay;
        }
    }

    /**
     * Reset the zoom. If the current zoom is the default, the previous zoom
     * level is restored.
     * 
     * @see DoubleTapDetector.Listener#onDoubleTapDetected()
     */
    public void onDoubleTapDetected() {
        if (_povMode) {
            // No zoom in point-of-view mode
            return;
        }
        
        if (_zoom != DEFAULT_ZOOM) {
            // Store the current zoom level and reset to default
            System.out.println("MazeCanvas.onDoubleTapDetected(): Resetting the default zoom.");
            _previousZoom = _zoom;
            _zoom = DEFAULT_ZOOM;
        }
        else {
            // Restore the previous zoom
            System.out.println("MazeCanvas.onDoubleTapDetected(): Restoring the previous zoom.");
            _zoom = _previousZoom;
        }
        
        calculateRelativeZoomAndCameraOffset();
        updateCamera();
    }

    /**
     * From SafeGestureListener.
     * 
     * Handles pinch gestures and changes the distance of the camera based on
     * the gesture. 
     *
     * @see SafeGestureListener#gestureAction(java.lang.Object,
     * SafeGestureInteractiveZone, SafeGestureEvent)
     */
    public void gestureAction(Object container,
                              SafeGestureInteractiveZone gestureInteractiveZone,
                              SafeGestureEvent gestureEvent)
    {
        if (!_povMode && gestureEvent.getType() == SafeGestureInteractiveZone.GESTURE_PINCH) {
            if (gestureEvent.getPinchDistanceChange() < 0) {
                // If the gesture was inwards, scale smaller
                doZoom(ZOOM_INTERVAL);
            }
            else if (gestureEvent.getPinchDistanceChange() > 0) {
                // If the gesture was outwards, scale larger
                doZoom(-ZOOM_INTERVAL);
            }
        }
    }
    

    /**
     * @see Menu.Listener#onMenuItemSelected(int)
     */
    public void onMenuItemSelected(int index, MenuItem item) {
        if (_gameState == MazeCanvas.PAUSED && item != null) {
            final String text = item.text();
            
            if (text.equals(RESUME_MENU_ITEM_TEXT)) {
                // Resume
                resume();
            }
            else if (text.startsWith(SET_BACKGROUND_PREFIX_MENU_ITEM_TEXT)) {
                // Toggle background
                setBackground(!_hasBackground);
                item.setText(SET_BACKGROUND_PREFIX_MENU_ITEM_TEXT
                             + (_hasBackground ? "off" : "on"));
            }
            else if (text.startsWith(SWITCH_DEBUG_MODE_PREFIX_MENU_ITEM_TEXT)) {
                // Toggle debug mode
                _debugMode = !_debugMode;
                item.setText(SWITCH_DEBUG_MODE_PREFIX_MENU_ITEM_TEXT
                             + (_debugMode ? "off" : "on"));
            }
            else if (text.equals(RESTART_GAME_MENU_ITEM_TEXT)) {
                // Restart the game
                startNewGame();
            }
        }
    }

    /**
     * Sets the accelerometer calibration value for the Y axis.
     * @param calibrationY The value to set.
     */
    public void setCalibration(final double calibration) {
        _calibration = calibration;
        
        if (_calibration < MIN_CALIBRATION_VALUE
                && _calibration != UNDEFINED)
        {
            _calibration = MIN_CALIBRATION_VALUE;
        }
    }

    /**
     * Resets the input state.
     */
    public void resetMarbleVelocity() {
        _marbleModel.setVelocity(new float[] { 0f, 0f, 0f });
    }

    //-------------------------------------------------------------------------
    // <- UI interaction implementation
    //-------------------------------------------------------------------------

    /*
     * @see javax.microedition.lcdui.CommandListener#commandAction(javax.microedition.lcdui.Command, javax.microedition.lcdui.Displayable)
     */
    public void commandAction(Command command, Displayable displayble) {
        if (command == _exitAndBackCommand) {
            switch (_gameState) {
            case NOT_STARTED:
                if (_infoDialog != null) {
                    // Hide the info dialog
                    _infoDialog = null;
                }
                else {
                    _midlet.quitApp();
                }
                
                break;
            case ONGOING:
                pause();
                break;
            case PAUSED:
                if (_infoDialog != null) {
                    // Hide the info dialog
                    _infoDialog = null;
                    removeCommand(_exitAndBackCommand);
                }
                
                break;
            case GAME_OVER:
                _midlet.quitApp();
                break;
            default:
                break;
            }
        }
    }

    /**
     * @see com.nokia.mid.ui.orientation.OrientationListener#displayOrientationChanged(int)
     */
    public void displayOrientationChanged(int newDisplayOrientation) {
        System.out.println("MazeCanvas.displayOrientationChanged(): "
                + newDisplayOrientation);
        boolean orientationChanged = false;
        
        if (newDisplayOrientation == Orientation.ORIENTATION_LANDSCAPE
                && !_povMode && _isPortrait)
        {
            orientationChanged = true;
            _isPortrait = false;
        }
        else if (!_isPortrait) {
            orientationChanged = true;
            _isPortrait = true;
        }
        
        if (orientationChanged) {
            _iconButtons[IconButton.PAUSE].setIsPortrait(_isPortrait);
            _topCamera.setTransform(defaultTopCameraTransform());
            setCalibration(UNDEFINED);
        }
    }

    /**
     * A lot of the transition logic is done here.
     * @see com.nokia.example.amaze.ui.CameraAnimator.Listener#onAnimationFinished(int)
     */
    public void onAnimationFinished(int animationType) {
        System.out.println("MazeCanvas.onAnimationFinished(): " + animationType);
        
        switch (animationType) {
        case CameraAnimator.PAUSE_ANIMATION: {
            // Start a new animation to go to the current game view
            transitionToCurrentViewMode();
            break;
        }
        case CameraAnimator.TRANSITION_ANIMATION_TO_POV: {
            _world.setActiveCamera(_povCamera);
            _marble.setRenderingEnable(false); // Do not render the marble in POV mode
            _gameState = ONGOING;
            updateCamera();
            
            // Trigger a new average FPS calculation
            _averageFps = 0;
            _fpsCount = 0;
            break;
        }
        case CameraAnimator.TRANSITION_ANIMATION_TO_TOP: {
            _world.setActiveCamera(_topCamera);
            _marble.setRenderingEnable(true);
            _gameState = ONGOING;
            updateCamera();
            
            // Trigger a new average FPS calculation
            _averageFps = 0;
            _fpsCount = 0;
            break;
        }
        case CameraAnimator.LEVEL_RESET_ANIMATION_STEP: {
            if (_gameState == LEVEL_FINISHED) {
                // Advance a level
                nextLevel();
                transitionToCurrentViewMode();
            }
            else {
                // Game over, create a new level and go to the start view
                createNewLevel();
                showStartMenu();
            }
            
            break;
        }
        } // switch (animationType)
    }

    /**
     * Starts/stops the game thread. 
     * @param running If true, will start the thread/drawing. If false, will
     *                   stop the game thread.
     */
    public synchronized void setRunning(final boolean running) {
        _running = running;
        
        if (_running) {
            if (_mainThread == null) {
                _mainThread = new Thread(this);
                _mainThread.start();
            }
        }
    }

    /**
     * To control the background setting. Painting the background has a
     * significant impact on performance with low-performing devices.
     * @param on If true, will paint the background.
     */
    public void setBackground(final boolean on) {
        if (on && _background == null) {
            // Create the background
            _background = _builder.createBackground(_world);
        }
        else if (!on && _background != null) {
            // Clear the background
            _world.setBackground(null);
            _background = null;
        }
        
        _hasBackground = on;
        
        // Trigger a new average FPS calculation
        _averageFps = 0;
        _fpsCount = 0;
    }

    /**
     * Constructs and shows the start menu.
     */
    private void showStartMenu() {
        _gameState = NOT_STARTED;
         
        // Create the start menu
        _menu.clear();
        MyTimer.instance().removeListener(_blinkingMenuItem);
        _blinkingMenuItem.setText("Tap screen to start game");
        MyTimer.instance().addListener(_blinkingMenuItem,  true, MenuItem.BLINK_INTEVAL);
        _menu.addItem(_blinkingMenuItem);    
        
        // Set the camera
        final float width = (float)_width;
        final float height = (float)_height;
        _topCamera.setPerspective(60.f, width / height, 0.1f, 1000.f);
        Transform transform = new Transform();
        transform.postRotate(PAUSE_CAMERA_ANGLE, -1f, 0f, 0f);
        transform.postTranslate(0f, 0f,
                DEFAULT_ZOOM * MAZE_SIDE_LENGTH);
        _topCamera.setTransform(transform);
        updateCamera();
        
        // Start the pause animation  
        _cameraAnimator.startAnimation(CameraAnimator.PAUSE_ANIMATION);
        
        if (Main.HAS_ONE_KEY_BACK) {
            addCommand(_exitAndBackCommand);
        }
    }

    /**
     * Starts a new game.
     */
    public void startNewGame() {
        _goalReached = false;
        _level = 1;
        _levelStartTime = System.currentTimeMillis();
        _timeToBeat = LEVEL_TIME * 1000;
        _timeUsed = 0;
        
        if (_gameState == NOT_STARTED) {
            // Starting the game from the start menu (view)
            // No need to create a new level
            MyTimer.instance().removeListener(_blinkingMenuItem);
        }
        else {
            createNewLevel();
        }
        
        if (_cameraAnimator.running()) {
            _cameraAnimator.stopAnimation();
        }
        else {
            _gameState = ONGOING;
        }
        
        resetMarbleVelocity();
        TipBox.showTips(_height); // This only works when called the first time
        
        if (Main.HAS_ONE_KEY_BACK) {
            addCommand(_exitAndBackCommand);
        }
    }

    /**
     * Pauses the game.
     */
    public void pause() {
        if (_gameState == PAUSED) {
            // Already paused
            return;
        }
        
        // Create the pause menu
        _menu.clear();
        _menu.addItem(RESUME_MENU_ITEM_TEXT);
        _menu.addItem(SET_BACKGROUND_PREFIX_MENU_ITEM_TEXT
                      + (_hasBackground ? "off" : "on"));
        _menu.addItem(SWITCH_DEBUG_MODE_PREFIX_MENU_ITEM_TEXT
                      + (_debugMode ? "off" : "on"));
        _menu.addItem(RESTART_GAME_MENU_ITEM_TEXT);
        
        Transform targetTransform = new Transform();
        targetTransform.postRotate(PAUSE_CAMERA_ANGLE, -1f, 0f, 0f);
        targetTransform.postTranslate(0f, 0f, DEFAULT_ZOOM * MAZE_SIDE_LENGTH);
        
        _cameraAnimator.startAnimation(CameraAnimator.PAUSE_ANIMATION,
                                          targetTransform, null);
        
        // Resume will result in re-calibration
        setCalibration(UNDEFINED);
        
        // Update the game state
        _gameState = PAUSED;
        
        if (Main.HAS_ONE_KEY_BACK) {
            removeCommand(_exitAndBackCommand);
        }
    }

    /**
     * Resumes the current game.
     */
    public void resume() {
        System.out.println("MazeCanvas.resume()");
        
        if (_gameState == PAUSED) {
            _levelStartTime = System.currentTimeMillis() - _timeUsed;
            _cameraAnimator.stopAnimation();
            
            if (Main.HAS_ONE_KEY_BACK) {
                addCommand(_exitAndBackCommand);
            }
        }
        else {
            System.out.println("MazeCanvas.resume(): No game to resume!");
        }
    }

    /**
     * Ends the current game and shows the game over view.
     */
    public void endGame() {
        _gameState = GAME_OVER;
        _menu.clear();
        MenuItem item = new MenuItem("Game Over");
        item.setDisabled(true);
        _menu.addItem(item);
        _menu.addItem("You reached level " + _level);
        _blinkingMenuItem.setText("Tap screen to continue");
        MyTimer.instance().addListener(_blinkingMenuItem,  true, MenuItem.BLINK_INTEVAL);
        _menu.addItem(_blinkingMenuItem);
        _cameraAnimator.startAnimation(CameraAnimator.PAUSE_ANIMATION);
        
        if (Main.HAS_ONE_KEY_BACK) {
            addCommand(_exitAndBackCommand);
        }
    }

    /**
     * Finishes the level and prepares to start a new one. This method should be
     * called when the marble gets to the end of the maze.
     */
    public void finishLevel() {
        _gameState = LEVEL_FINISHED;
        _menu.clear();
        _menu.addItem("Level " + _level + " finished!");
        _cameraAnimator.startAnimation(CameraAnimator.LEVEL_RESET_ANIMATION_STEP);
    }

    /** 
     * Modifies the current zoom value.
     * @param diff The difference to current zoom.
     */
    public void doZoom(final float diff) {
        _zoom += diff;
        
        if (_zoom < MIN_ZOOM) {
            _zoom = MIN_ZOOM;
        }
        else if (_zoom > MAX_ZOOM) {
            _zoom = MAX_ZOOM;
        }
        
        calculateRelativeZoomAndCameraOffset();
        updateCamera();
    }

    /**
     * Switches from top view to normal view and vice versa.
     */
    public void toggleViewMode() {
        _povMode = !_povMode;
        System.out.println("MazeCanvas.toggleViewMode(): To "
                + (_povMode ? "POV" : "TOP"));
        
        if (_povMode) {
            _iconButtons[IconButton.VIEW_MODE].setPressed(true);
        }
        else {
            _iconButtons[IconButton.VIEW_MODE].setPressed(false);
        }
        
        // Do the transition animation.
        transitionToCurrentViewMode();
    }

    /**
     * Sets the button with the given ID in pressed state. If the ID does not
     * match any of the buttons, all buttons are set in unpressed state.
     * @param id The ID of the buttonn.
     */
    public void setButtonPressed(int id) {
        if (!Main.HAS_ONE_KEY_BACK) {
            _iconButtons[IconButton.EXIT].setPressed(false);
        }
        
        _iconButtons[IconButton.INFO].setPressed(false);
        _iconButtons[IconButton.PAUSE].setPressed(false);
        
        if (id == IconButton.EXIT
            || id == IconButton.INFO
            || id == IconButton.PAUSE)
        {
            _iconButtons[id].setPressed(true);
        }
    }

    /**
     * Shifts the background to support the illusion when the camera rotates.
     * @param delta The amount the background is shifted. A positive value
     *                 shifts the background to the right and negative to the
     *                 left.
     */
    public final void shiftBackground(final int delta) {
        if (_background != null) {
            _background.setCrop(_background.getCropX() + delta, 0,
                                _width, _height);
        }
    }

    /**
     * Updates the properties of the active camera. Note that this is very
     * expensive operation!
     */
    public void updateCamera() {
        final float[] position = _marbleModel.position();
        
        if (_povMode) {
            _povCamera.setOrientation(_povAngleY, 0f, 1f, 0f);
            _povCamera.setTranslation(position[0], 10f, position[2]);
        }
        else {
            // Follow the marble with the camera but if the view is zoomed out
            // i.e. the maze and the marble are smaller, then try to show more
            // of the maze.
            _topCamera.setTranslation(position[0] / _cameraOffset, _zoom,
                    position[2] / _cameraOffset);
        }
    }

    /**
     * Checks the possible collision between the marble and the walls.
     * @param x
     * @param z
     * @return - 0 if no collision
     *         - 1 if collision on X axis
     *         - 2 if collision on Z axis
     *         - 3 if collision on both axis
     */
    private final int marbleCollidesAt(final float x, final float z) {
        // First check if the target is inside the maze area
        float mazeSize = MAZE_SIDE_LENGTH / 2 - 2;
        
        int outside = 0;
        
        if (x - MarbleModel.DEFAULT_SIZE_HALVED <= -mazeSize
                || x + MarbleModel.DEFAULT_SIZE_HALVED > mazeSize)
        {
            outside = 1;
        }
        if (z - MarbleModel.DEFAULT_SIZE_HALVED <= -mazeSize
                || z + MarbleModel.DEFAULT_SIZE_HALVED > mazeSize)
        {
            outside += 2;
        }
        if (outside > 0) {
            System.out.println("MazeCanvas.canMoveTo(): Would go outside of the maze.");
            return outside;
        }
        
        RayIntersection intersection = new RayIntersection();
        final float minimumDistance = MarbleModel.DEFAULT_SIZE + 0.5f;
        
        if (_world.pick(-1, x, MarbleModel.DEFAULT_SIZE_HALVED, z,
                MarbleModel.DEFAULT_SIZE_HALVED, 0.1f,
                MarbleModel.DEFAULT_SIZE_HALVED,
                intersection))
        {
            Node selected = intersection.getIntersected();
            
            if (selected instanceof Mesh) {
                float distance = intersection.getDistance();
                
                if (distance < minimumDistance) {
                    float[] position = _marbleModel.position();
                    int retval = 0;
                    
                    // Check X axis
                    if (_world.pick(-1, x, MarbleModel.DEFAULT_SIZE_HALVED, position[2],
                            MarbleModel.DEFAULT_SIZE_HALVED, 0.1f,
                            MarbleModel.DEFAULT_SIZE_HALVED,
                            intersection))
                    {
                        distance = intersection.getDistance();
                        
                        if (distance < minimumDistance) {
                            retval = 1;
                        }
                    }
                    
                    // Check Z axis
                    if (_world.pick(-1, position[0], MarbleModel.DEFAULT_SIZE_HALVED, z,
                            MarbleModel.DEFAULT_SIZE_HALVED, 0.1f,
                            MarbleModel.DEFAULT_SIZE_HALVED,
                            intersection))
                    {
                        distance = intersection.getDistance();
                        
                        if (distance < minimumDistance) {
                            retval += 2;
                        }
                    }
                    
                    return retval;
                }
            }
        }
        
        return 0;
    }

    /**
     * Creates a new level. The old level, if one exists, will be destroyed.
     */
    private void createNewLevel() {
        System.out.println("MazeCanvas.createNewLevel()");
        _builder.createNewMaze(_world, _maze, _wallAppearance);
        
        // Set the marble's initial location
        _marbleModel.setPosition(_maze.startPosition());
    }

    /**
     * Recreates the maze and re-initializes the values for the next level. 
     */
    private void nextLevel() {
        System.out.println("MazeCanvas.nextLevel()");
        _goalReached = false;
        _level++;
        _levelStartTime = System.currentTimeMillis();
        
        _timeToBeat = LEVEL_TIME * 1000
                + _timeToBeat - _timeUsed // Add the time left from previous level
                - (_level - 1) * 5000; // Take 5 seconds off on each level
        
        if (_timeToBeat < 10000) {
            // Keep it ten seconds minimum
            _timeToBeat = 10000;
        }
        
        _timeUsed = 0;
        
        createNewLevel();
        updateCamera();
        resetMarbleVelocity();
    }

    /**
     * Returns the default top camera transform.
     */
    private Transform defaultTopCameraTransform() {
        Transform transform = new Transform();
        
        if (_isPortrait) {
            transform.postRotate(DEFAULT_TOP_CAMERA_ANGLE, -1f, 0f, 0f);
        }
        else {
            transform.postRotate(90, -1f, 0f, 0f);
            transform.postRotate(90 - DEFAULT_TOP_CAMERA_ANGLE, 0f, -1f, 0f);
        }
        
        transform.postTranslate(0f, 0f, MAZE_SIDE_LENGTH);
        return transform;
    }

    /**
     * Starts a camera animation based on the current view mode setting.
     */
    private void transitionToCurrentViewMode() {
        if (_povMode) {
            _cameraAnimator.startAnimation(CameraAnimator.TRANSITION_ANIMATION_TO_POV,
                                           _povCamera);
        }
        else {
            Transform transform = defaultTopCameraTransform();
            float[] orientation = new float[4];
            orientation[0] = 0;
            orientation[1] = 0;
            orientation[2] = 0;
            orientation[3] = 1;
            _cameraAnimator.startAnimation(CameraAnimator.TRANSITION_ANIMATION_TO_TOP,
                                           transform, orientation);
        }
    }

    /**
     * Calculates and returns the relative zoom [0.0 - 1.0], where 0 is the
     * maximum zoom in and 1 the maximum zoom out.
     * @return The relative zoom.
     */
    private final void calculateRelativeZoomAndCameraOffset() {
        final int scale = MazeCanvas.MAX_ZOOM - MazeCanvas.MIN_ZOOM;
        _relativeZoom =  (_zoom - (float)MazeCanvas.MIN_ZOOM) / scale;
        
        _cameraOffset = _relativeZoom * 4;
        
        if (_cameraOffset < 1) {
            _cameraOffset = 1;
        }
    }

    /**
     * Tilts the board based on the current accelerometer sensor readings.
     */
    private final void tiltBoard() {
        // Tilt the board based on the accelerometer readings
        final float tiltX = (float)(-_ax);
        final float tiltY = (float)(-_ay);
        boolean updateTilt = false;
        
        if (Math.abs(tiltX - _prevTiltX) > TILT_THRESHOLD) {
            _prevTiltX = tiltX;
            updateTilt = true;
        }
        
        if (Math.abs(tiltY - _prevTiltY) > TILT_THRESHOLD) {
            _prevTiltY = tiltY;
            updateTilt = true;
        }
        
        if (updateTilt) {
            // Calculate the tilt coefficient based on the current zoom
            final float coefficient = _relativeZoom * MAX_TILT_COEFFICIENT;
            
            // Update the camera angle based on the accelerometer values
            _topCamera.setOrientation(_prevTiltY * coefficient, 1f, 0f, 0f);
            _topCamera.postRotate(_prevTiltX * coefficient, 0f, 0f, 1f);
        }    
    }

    /**
     * Moves the marble when in POV mode.
     * 
     * Note that this method does not update the camera.
     */
    private final void moveMarbleInPovMode() {
        // Check if we should be turning to some direction
        if (Math.abs(_ax) > ACCELERATION_THRESHOLD) {
            _povAngleY += (float)_ax * ACCELERATION_COEFFICIENT_X;
            shiftBackground((int)(-_ax * ACCELERATION_COEFFICIENT_X) * 3);
        }
        
        // Check if we should be moving back or forth
        final float absCalibratedAy =(float)Math.abs(_ay);
        
        if (absCalibratedAy > ACCELERATION_THRESHOLD) {
            final int mark = (_ay > 0) ? 1 : -1;
            
            final float stepLength =
                    DEFAULT_STEP_LENGTH_PER_AXIS
                    * (absCalibratedAy * ACCELERATION_COEFFICIENT_Y);
            
            final float stepX = mark * stepLength * (float)Math.sin(Math.toRadians(_povAngleY));
            final float stepZ = mark * stepLength * (float)Math.cos(Math.toRadians(_povAngleY)); 
            final float[] position = _marbleModel.position();
            final int collision =
                marbleCollidesAt(position[0] + stepX, position[2] + stepZ);
            
            if (collision == 0) {
                _marbleModel.move(stepX, 0, stepZ);
            }
        }
    }

    /**
     * Calculates the velocity of the marble based on the current accelerometer
     * sensor readings and moves the marble based on the velocity and the
     * collision detection.
     * 
     * Note that this method does not update the camera.
     * 
     * @param ticks The milliseconds since last time the method was called.
     */
    private final void moveMarble(final int ticks) {
        // Calculate the velocity
        float[] velocity = _marbleModel.calculateVelocity(_ax, _ay, ticks);
        
        // Check for collisions
        int collision = -1;
        int safety = 0; // To prevent forever loop
        final float[] position = _marbleModel.position();
        final float ticksCoefficient = (float)ticks / MazeCanvas.TICKS_COEFFICIENT;
        
        while (collision != 0 && safety < 4) {
            collision = marbleCollidesAt(
                position[0] + velocity[0] * ticksCoefficient,
                position[2] + velocity[2] * ticksCoefficient);
            
            if (collision == 1 || collision == 3) { // X axis or both 
                velocity[0] = -velocity[0] * MarbleModel.DEFAULT_BOUNCE_DRAG;
            }
            else if (collision == 2 || collision == 3) { // Z axis or both
                velocity[2] = -velocity[2] * MarbleModel.DEFAULT_BOUNCE_DRAG;
            }
            
            safety++;
        }
        
        // Set the velocity and move the marble
        _marbleModel.setVelocity(velocity);
        _marbleModel.move(ticks);
    }

    /**
     * Draws 2D graphics e.g. information on top of the screen.
     * @param graphics
     */
    private void draw2D(Graphics graphics) {
        graphics.setColor(TEXT_COLOR);
        final Font defaultFont = Font.getDefaultFont();
        graphics.setFont(defaultFont);
        
        if (_debugMode) {
            // Draw the accelerometer sensor readings in the bottom
            String ax = String.valueOf(_ax);
            String ay = String.valueOf(_ay);
            ax = ax.length() > 4 ? ax.substring(0, 4) : ax;
            ay = ay.length() > 4 ? ay.substring(0, 4) : ay;
            graphics.drawString("A: [" + ax + ", " + ay + "]",
                    42, _height - defaultFont.getHeight(),
                    Graphics.BOTTOM | Graphics.LEFT);
            
            // Draw the FPS
            graphics.drawString("FPS: " + _fps + " (" + 
                    ((_fpsCount < MIN_FPS_STATS_COUNT) ? -1 : _averageFps) + ")",
                    42, _height, Graphics.BOTTOM | Graphics.LEFT);
        }
        
        if (_isPortrait
                && (_gameState == ONGOING
                || _gameState == PAUSED
                || _gameState == GAME_OVER))
        {
            // Draw the current level in the top-left corner
            StringBuffer buffer = new StringBuffer();
            buffer.append("Level ").append(_level);
            graphics.drawString(buffer.toString(), MARGIN, MARGIN,
                    Graphics.TOP | Graphics.LEFT);
            
            // Draw the time left in the top-right corner
            buffer.delete(0,  buffer.length());
            buffer.append("Time left: ");
            buffer.append((_timeToBeat - _timeUsed) / 1000);
            graphics.drawString(buffer.toString(), _width - MARGIN, MARGIN,
                    Graphics.TOP | Graphics.RIGHT);
        }
        
        if (_gameState == NOT_STARTED
                || _gameState == PAUSED
                || _gameState == GAME_OVER)
        {
            if (_infoDialog == null) {
                // Draw the menu
                _menu.paint(graphics);
                
                // Draw the info and exit button
                _iconButtons[IconButton.INFO].paint(graphics, MARGIN, _height - 36);
                
                if (!Main.HAS_ONE_KEY_BACK) {
                    _iconButtons[IconButton.EXIT].paint(graphics, _width - 36, _height - 36);
                }
            }
            else {
                _infoDialog.paint(graphics);
            }
        }
        else if (_gameState == ONGOING) {
            // Draw the view mode and pause buttons
            _iconButtons[IconButton.PAUSE].paint(graphics, _width - 36, _height - 36);
            
            if (_isPortrait) {
                _iconButtons[IconButton.VIEW_MODE].paint(graphics, MARGIN, _height - 36);
                
                if (TipBox.visible()) {
                    // Draw the tip box
                    TipBox.paint(graphics);
                }
            }
        }
        else if (_gameState == LEVEL_FINISHED) {
            _menu.paint(graphics); // Shows "Level X finished!"
        }
    }

    /**
     * Renders the 3D objects of the scene.
     * @param graphics The Graphics instance.
     * @param ticks The time elapsed since last rendering in milliseconds.
     */
    private final void draw3D(final Graphics graphics, final int ticks) {
        boolean bound = false;
        
        try {
            // Bind the target
            _graphics3d.bindTarget(graphics);
            bound = true;
            
            // Advance the animation
            _world.animate(ticks);
            
            // Do the rendering
            _graphics3d.render(_world);
        }
        finally {
            // Release the target
            if (bound) {
                _graphics3d.releaseTarget();
            }
        }
    }
}