Aprende a crear juegos en HTML5 Canvas

domingo, 7 de julio de 2013

Almacenamiento local y altos puntajes

Existen veces que necesitamos almacenar información del jugador dentro de su computadora. Por ejemplo, en juegos muy grandes de varios niveles, es posible que queramos conocer el nivel en el que se quedó nuestro jugador.

Para almacenar un dato en el navegador del usuario, utilizamos el Almacenamiento Local. Usarlo es bastante sencillo, únicamente debemos escribir localStorage, un punto, y el nombre de la variable con la que queremos guardar nuestro dato. Por ejemplo, para guardar el nivel en el que está nuestro jugador, escribimos esta línea:
localStorage.level = level;
Este código sería buena idea ejecutarlo al pasar un nuevo nivel. Para recuperar ese dato, únicamente debemos comparar, en la función init, si el almacenamiento local tiene un valor en dicha variable, y de ser cierto, la asignamos a la variable de nuestro juego:
if (localStorage.level) {
    level = localStorage.level;
}
Aun si cierras el navegador, apagas la computadora y regresas al día siguiente, los datos seguirán ahí disponibles para que continúes el juego donde lo dejaste.

Para ejemplificar de forma más clara las posibilidades que se tienen con el Almacenamiento Local, les mostraré como crear una lista de altos puntajes, almacenarla en el almacenamiento local, y recuperarla. Comencemos creando el arreglo que contendrá nuestros altos puntajes, y una variable que contendrá la posición de nuestro nuevo mejor puntaje:
    var highscores = [],
        posHighscore = 10;
Para agregar un nuevo dato, se creará una función la cual será llamada al momento de que acabe nuestro juego (Por ejemplo, cuando el jugador pierda), a la cual enviaremos el puntaje del jugador, e intentará agregarlo a los mejores puntajes. Esta función será de la siguiente forma:
    function addHighscore(score) {
        posHighscore = 0;
        while (highscores[posHighscore] > score && posHighscore < highscores.length) {
            posHighscore += 1;
        }
        highscores.splice(posHighscore, 0, score);
        if (highscores.length > 10) {
            highscores.length = 10;
        }
        localStorage.highscores = highscores.join(',');
    }
Para comprender mejor esta función, la explicaré por partes. Comenzamos buscando la posición de nuestro nuevo alto puntaje, asignándole el valor de cero, y mientras puntaje en la posición actual sea mayor al puntaje actual, o la posición alcance el final de la longitud de los altos puntajes, su valor se irá sumando en uno. Así se encontrará donde va nuestro nuevo puntaje en la lista. Una vez localizada la posición, se inserta al arreglo mediante la función "splice", mandando la posición, el valor de elementos a eliminar (0), y el nuevo valor a insertar (el puntaje).

Cuando hemos insertado el nuevo valor, verificaremos si la longitud del arreglo es mayor a 10, y de ser así, recortamos su longitud a solo 10 elementos. Así, mantendremos solo los 10 mejores puntajes almacenados.

Por último, guardamos nuestros puntajes el el almacenamiento local. Como el almacenamiento local no admite arreglos, primero debemos convertir el arreglo a texto. Esto se hace de forma muy sencilla con la función "join", que une todos los elementos del arreglo con una coma, quedando nuestro arreglo de una forma similar a esta:
"27,23,21,19,15,12,11,9,8,7"
Para recuperar ahora los datos de los altos puntajes, simplemente separamos los datos del almacenamiento local mediante la función "split". Esto lo hacemos dentro de la función init:
        // Load saved highscores
        if (localStorage.highscores) {
            highscores = localStorage.highscores.split(',');
        }
Nota que estos datos son siempre separados por una coma. Si almacenarás un arreglo de textos que pueda contener comas, necesitarás un símbolo distinto. Por ejemplo, si quieres almacenar algún texto capturado por parte del usuario, recomiendo usar nuevas líneas ('\n') o tabuladores ('\t'), ya que estos son poco probables de ser insertados por el usuario dentro de una caja de texto.

Finalmente, se imprimirán los mejores puntajes en la pantalla. Para ello crearemos una nueva escena donde se mostrarán:
    // Highscore Scene
    highscoresScene = new Scene();

    highscoresScene.paint = function (ctx) {
        var i = 0,
            l = 0;
        
        // Clean canvas
        ctx.fillStyle = '#030';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        // Draw title
        ctx.fillStyle = '#fff';
        ctx.textAlign = 'center';
        ctx.fillText('HIGH SCORES', 150, 30);
        
        // Draw high scores
        ctx.textAlign = 'right';
        for (i = 0, l = highscores.length; i < l; i += 1) {
            if (i === posHighscore) {
                ctx.fillText('*' + highscores[i], 180, 40 + i * 10);
            } else {
                ctx.fillText(highscores[i], 180, 40 + i * 10);
            }
        }
    };

    highscoresScene.act = function () {
        // Load next scene
        if (lastPress === KEY_ENTER) {
            loadScene(gameScene);
            lastPress = null;
        }
    };
Los altos puntajes son impresos dentro de un ciclo for. En él, comparo si la posición actual es igual a la posición del nuevo alto puntaje, y de ser así, imprimo un asterisco antes del puntaje para conocer cual ha sido el nuevo puntaje dentro de la lista. Habrás notado que al comienzo, declaré la posición con la longitud máxima de los altos puntajes (10), esto para que al comienzo del juego, el asterisco no apareciera en la lista por accidente.

Para llamar a la escena de altos puntajes, he cambiado la llamada a "mainScene" en "gameScene.act" para mostrarlos al final de cada juego. También lo he cambiado la llamada a "gameScene" en "mainScene.act" al comienzo, para mostrar los altos puntajes al comienzo del juego, y saber cuales son los mejores puntajes a vencer.

Con esto, tendremos nuestra lista de mejores puntajes almacenada. Te recuerdo que estos datos son locales en la computadora de cada usuario. Si quieres almacenarlos de forma global para todos los que usen tu juego, esto requiere de un lenguaje servidor como PHP, pero en este curso no nos adentraremos en esa clase de temas.

Para probar el código, lo he implementado al juego de la serpiente. Puedes jugarlo, irte, regresar en unas semanas, y verás que aquí seguirán tus mejores puntuaciones, esperando a que las superes. ¡Diviertete! ¡Y felices códigos!

Código final:

[Canvas not supported by your browser]
/*jslint bitwise:true, es5: true */
(function (window, undefined) {
    'use strict';
    var KEY_ENTER = 13,
        KEY_LEFT = 37,
        KEY_UP = 38,
        KEY_RIGHT = 39,
        KEY_DOWN = 40,
        
        canvas = null,
        ctx = null,
        lastPress = null,
        pause = false,
        gameover = false,
        currentScene = 0,
        scenes = [],
        mainScene = null,
        gameScene = null,
        highscoresScene = null,
        body = [],
        food = null,
        //var wall = [],
        highscores = [],
        posHighscore = 10,
        dir = 0,
        score = 0,
        iBody = new Image(),
        iFood = new Image(),
        aEat = new Audio(),
        aDie = new Audio();

    window.requestAnimationFrame = (function () {
        return window.requestAnimationFrame ||
            window.mozRequestAnimationFrame ||
            window.webkitRequestAnimationFrame ||
            function (callback) {
                window.setTimeout(callback, 17);
            };
    }());

    document.addEventListener('keydown', function (evt) {
        if (evt.which >= 37 && evt.which <= 40) {
            evt.preventDefault();
        }

        lastPress = evt.which;
    }, false);
    
    function Rectangle(x, y, width, height) {
        this.x = (x === undefined) ? 0 : x;
        this.y = (y === undefined) ? 0 : y;
        this.width = (width === undefined) ? 0 : width;
        this.height = (height === undefined) ? this.width : height;
    }

    Rectangle.prototype = {
        constructor: Rectangle,
        
        intersects: function (rect) {
            if (rect === undefined) {
                window.console.warn('Missing parameters on function intersects');
            } else {
                return (this.x < rect.x + rect.width &&
                    this.x + this.width > rect.x &&
                    this.y < rect.y + rect.height &&
                    this.y + this.height > rect.y);
            }
        },
        
        fill: function (ctx) {
            if (ctx === undefined) {
                window.console.warn('Missing parameters on function fill');
            } else {
                ctx.fillRect(this.x, this.y, this.width, this.height);
            }
        },
        
        drawImage: function (ctx, img) {
            if (img === undefined) {
                window.console.warn('Missing parameters on function drawImage');
            } else {
                if (img.width) {
                    ctx.drawImage(img, this.x, this.y);
                } else {
                    ctx.strokeRect(this.x, this.y, this.width, this.height);
                }
            }
        }
    };

    function Scene() {
        this.id = scenes.length;
        scenes.push(this);
    }

    Scene.prototype = {
        constructor: Scene,
        load: function () {},
        paint: function (ctx) {},
        act: function () {}
    };

    function loadScene(scene) {
        currentScene = scene.id;
        scenes[currentScene].load();
    }
    
    function random(max) {
        return ~~(Math.random() * max);
    }

    function addHighscore(score) {
        posHighscore = 0;
        while (highscores[posHighscore] > score && posHighscore < highscores.length) {
            posHighscore += 1;
        }
        highscores.splice(posHighscore, 0, score);
        if (highscores.length > 10) {
            highscores.length = 10;
        }
        localStorage.highscores = highscores.join(',');
    }

    function repaint() {
        window.requestAnimationFrame(repaint);
        if (scenes.length) {
            scenes[currentScene].paint(ctx);
        }
    }

    function run() {
        setTimeout(run, 50);
        if (scenes.length) {
            scenes[currentScene].act();
        }
    }

    function init() {
        // Get canvas and context
        canvas = document.getElementById('canvas');
        ctx = canvas.getContext('2d');

        // Load assets
        iBody.src = 'assets/body.png';
        iFood.src = 'assets/fruit.png';
        aEat.src = 'assets/chomp.m4a';
        aDie.src = 'assets/dies.m4a';

        // Create food
        food = new Rectangle(80, 80, 10, 10);

        // Create walls
        //wall.push(new Rectangle(50, 50, 10, 10));
        //wall.push(new Rectangle(50, 100, 10, 10));
        //wall.push(new Rectangle(100, 50, 10, 10));
        //wall.push(new Rectangle(100, 100, 10, 10));

        // Load saved highscores
        if (localStorage.highscores) {
            highscores = localStorage.highscores.split(',');
        }
        
        // Start game
        run();
        repaint();
    }

    // Main Scene
    mainScene = new Scene();

    mainScene.paint = function (ctx) {
        // Clean canvas
        ctx.fillStyle = '#030';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        // Draw title
        ctx.fillStyle = '#fff';
        ctx.textAlign = 'center';
        ctx.fillText('SNAKE', 150, 60);
        ctx.fillText('Press Enter', 150, 90);
    };

    mainScene.act = function () {
        // Load next scene
        if (lastPress === KEY_ENTER) {
            loadScene(highscoresScene);
            lastPress = null;
        }
    };

    // Game Scene
    gameScene = new Scene();

    gameScene.load = function () {
        score = 0;
        dir = 1;
        body.length = 0;
        body.push(new Rectangle(40, 40, 10, 10));
        body.push(new Rectangle(0, 0, 10, 10));
        body.push(new Rectangle(0, 0, 10, 10));
        food.x = random(canvas.width / 10 - 1) * 10;
        food.y = random(canvas.height / 10 - 1) * 10;
        gameover = false;
    };

    gameScene.paint = function (ctx) {
        var i = 0,
            l = 0;
        
        // Clean canvas
        ctx.fillStyle = '#030';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        // Draw player
        ctx.strokeStyle = '#0f0';
        for (i = 0, l = body.length; i < l; i += 1) {
            body[i].drawImage(ctx, iBody);
        }
        
        // Draw walls
        //ctx.fillStyle = '#999';
        //for (i = 0, l = wall.length; i < l; i += 1) {
        //    wall[i].fill(ctx);
        //}
        
        // Draw food
        ctx.strokeStyle = '#f00';
        food.drawImage(ctx, iFood);

        // Draw score
        ctx.fillStyle = '#fff';
        ctx.textAlign = 'left';
        ctx.fillText('Score: ' + score, 0, 10);
        
        // Debug last key pressed
        //ctx.fillText('Last Press: '+lastPress,0,20);
        
        // Draw pause
        if (pause) {
            ctx.textAlign = 'center';
            if (gameover) {
                ctx.fillText('GAME OVER', 150, 75);
            } else {
                ctx.fillText('PAUSE', 150, 75);
            }
        }
    };

    gameScene.act = function () {
        var i = 0,
            l = 0;
        
        if (!pause) {
            // GameOver Reset
            if (gameover) {
                loadScene(highscoresScene);
            }

            // Move Body
            for (i = body.length - 1; i > 0; i -= 1) {
                body[i].x = body[i - 1].x;
                body[i].y = body[i - 1].y;
            }

            // Change Direction
            if (lastPress === KEY_UP && dir !== 2) {
                dir = 0;
            }
            if (lastPress === KEY_RIGHT && dir !== 3) {
                dir = 1;
            }
            if (lastPress === KEY_DOWN && dir !== 0) {
                dir = 2;
            }
            if (lastPress === KEY_LEFT && dir !== 1) {
                dir = 3;
            }

            // Move Head
            if (dir === 0) {
                body[0].y -= 10;
            }
            if (dir === 1) {
                body[0].x += 10;
            }
            if (dir === 2) {
                body[0].y += 10;
            }
            if (dir === 3) {
                body[0].x -= 10;
            }

            // Out Screen
            if (body[0].x > canvas.width - body[0].width) {
                body[0].x = 0;
            }
            if (body[0].y > canvas.height - body[0].height) {
                body[0].y = 0;
            }
            if (body[0].x < 0) {
                body[0].x = canvas.width - body[0].width;
            }
            if (body[0].y < 0) {
                body[0].y = canvas.height - body[0].height;
            }

            // Food Intersects
            if (body[0].intersects(food)) {
                body.push(new Rectangle(0, 0, 10, 10));
                score += 1;
                food.x = random(canvas.width / 10 - 1) * 10;
                food.y = random(canvas.height / 10 - 1) * 10;
                aEat.play();
            }

            // Wall Intersects
            //for (i = 0, l = wall.length; i < l; i += 1) {
            //    if (food.intersects(wall[i])) {
            //        food.x = random(canvas.width / 10 - 1) * 10;
            //        food.y = random(canvas.height / 10 - 1) * 10;
            //    }
            //
            //    if (body[0].intersects(wall[i])) {
            //        gameover = true;
            //        pause = true;
            //    }
            //}

            // Body Intersects
            for (i = 2, l = body.length; i < l; i += 1) {
                if (body[0].intersects(body[i])) {
                    gameover = true;
                    pause = true;
                    aDie.play();
                    addHighscore(score);
                }
            }
        }
        // Pause/Unpause
        if (lastPress === KEY_ENTER) {
            pause = !pause;
            lastPress = null;
        }
    };

    // Highscore Scene
    highscoresScene = new Scene();

    highscoresScene.paint = function (ctx) {
        var i = 0,
            l = 0;
        
        // Clean canvas
        ctx.fillStyle = '#030';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        // Draw title
        ctx.fillStyle = '#fff';
        ctx.textAlign = 'center';
        ctx.fillText('HIGH SCORES', 150, 30);
        
        // Draw high scores
        ctx.textAlign = 'right';
        for (i = 0, l = highscores.length; i < l; i += 1) {
            if (i === posHighscore) {
                ctx.fillText('*' + highscores[i], 180, 40 + i * 10);
            } else {
                ctx.fillText(highscores[i], 180, 40 + i * 10);
            }
        }
    };

    highscoresScene.act = function () {
        // Load next scene
        if (lastPress === KEY_ENTER) {
            loadScene(gameScene);
            lastPress = null;
        }
    };
    
    window.addEventListener('load', init, false);
}(window));

15 comentarios:

  1. Tengo dos dudas:

    -Yo tengo debajo del juego texto y quando pulso las teclas de arriva y abajo me hace scroll, hay alguna forma porque esto no pase.

    - Se puede hacer una lista de las mejores puntuaciones de todas las personas que entren en la web.se podria haver con un txt. almacenado en la web?

    Gracias por los tutos

    ResponderBorrar
    Respuestas
    1. A tu primer duda, debes hacer un preventDefault(); cuando estas teclas se presionen. Yo lo hago de esta forma en el blog:

      if(lastPress>=37&&lastPress<=40)
      preventDefault();

      A tu segunda duda, esto se puede hace mediante un lenguaje lado servidor. Si ya sabes como manejar uno, solo investiga como modificar archivos con él, y usa ese proceso para guardar la información formateada en un string, como se hace aquí en el localStorage.

      ¡Mucha suerte! ¡Felices códigos!

      Borrar
    2. gracias lo de las puntuaciones lo dejare para mas adelante.

      El preventDefault lo he buscado mejor porque no me funcionaba y tiene que ser un evt.preventDefault

      Borrar
    3. ¡Vaya! ¡Es cierto! Olvidé especificar que era una función de evt. Me alegro que hayas podido encontrar la solución a pesar de mi error. ¡Suerte con lo otro!

      Borrar
  2. Hola!
    Muy práctico lo del "localStorage".
    Pero tengo una duda.
    No consigo mostrar una caja de texto dentro del canvas para que el usuario introduzca su nombre. Supongo que será algo como "fillTextarea" o algo asi, pero no doy con ello.

    Gracias y un saludo!
    Aupa!

    ResponderBorrar
    Respuestas
    1. Canvas no incluye controles de entrada, por eso nosotros tenemos que simular botones de forma tradicional. Para un input, las soluciones sencillas son un prompt como pop-up o poner un input encima del canvas para que paresca ser parte del juego.

      Si deseas que el input esté verdaderamente integrada en el canvas, es posible simularle tambien, pero no es sencillo del todo. ¡Suerte!

      Borrar
  3. Ya me olía algo así, ya que por más que buscaba en google no encontraba nada sobre inputs dentro de canvas.
    Probaré a poner un input a continuacion del canvas y posicionarlo sobre el canvas con CSS.
    Gracias por la info Karl...!
    Aupa!

    ResponderBorrar
  4. Muy buenos sus tutoriales, e aprendido mucho gracias a ellos, solo tengo una pregunta estoy trantando de agregar las parte de localStorage.level=level;
    pero lo logro que me funcione, alguna sugerencia de como hacer esta parte.

    Muchas gracias de ante mano y espero sigas haciendo mas tutoriales.

    ResponderBorrar
    Respuestas
    1. Supongo que incluiste también la línea donde lo lees. ¿Podrias especificarme que es lo que ocurre o no ocurre que no funciona?

      Borrar
    2. Para leerlo modifique uno de los 'if' que hicimos

      if(player.intersects(food)){
      score++;
      food.x=random(canvas.width/10-1)*10;
      food.y=random(canvas.height/10-1)*10;
      addHighscore(score);
      pause=true;
      localStorage.level=level;
      }

      y al igual al tratar de mostrar el level en el que se esta me sale undefine usando esta linea de codigo
      ctx.fillText('Score: '+localStorage.level,0,10);

      Gracias espero no confundirlo mucho con lo que trato de hacer

      Borrar
  5. Para leerlo modifique uno de los 'if' que hicimos

    if(player.intersects(food)){
    score++;
    food.x=random(canvas.width/10-1)*10;
    food.y=random(canvas.height/10-1)*10;
    addHighscore(score);
    pause=true;
    localStorage.level=level;
    }

    y al igual al tratar de mostrar el level en el que se esta me sale undefine usando esta linea de codigo
    ctx.fillText('Score: '+localStorage.level,0,10);

    Gracias espero no confundirlo mucho con lo que trato de hacer

    ResponderBorrar
    Respuestas
    1. Creo que lo que tu deseas es guardar localStorage.score y no localStorage.level. Prueba con este cambio para ver si funciona.

      Por cierto ¿Cual es el propósito de hacerlo de esta forma? No veo en que forma guardar esa información en ese momento pueda ser necesario, pero podría solo no estar considerando alguna opción que tengas en mente.

      Borrar
  6. Hola, excelente curso, muchas gracias, apenas lo comence voy en la unidad 1, tengo una duda, el localStorage sirve como una cookie? osea muere? y no se mi sueño guajiro a corto plazo es hacer un juego de rpg, como se podria hacer un archivito, no se que supongamos que hoy juego en mi casa y mañana en la de mi amigo, cargar ese archivito a la pagina para comenzar la partida, digo se podria hacer si se trabaja con un servidor en php, se hace un sistema con base de datos y guarde usuarios y partidas, vida items, pero supongamos si se hace aqui? o en este tipo de paginas como blogger que no se tiene acceso a php o crear bases de datos

    ResponderBorrar
    Respuestas
    1. LocalStorage es una versión mejorada y mas segura de las cookies, por que no pueden ser accesadas por el servidor, lo que asegura que páginas maliciosas no podrán revisar esa información. Esta información es persistente, por lo que se mantendrá ahí a menos que el usuario lo borre manualmente, o eliminando toda la información del navegador.

      Lo de guardar un archivo no me ha quedado del todo claro. Si lo quieres guardar del lado servidor, es necesario hacerlo mediante PHP o similar, pero me dio a entender que quieres crear un descargable y volverlo a cargar manualmente. Según vi hace un par de años, de trabajaba en un modulo de JS para lectura y escritura de archivos, pero desconozco su estado actual. ¿Es esto segundo lo que deseas?

      Borrar