Aprende a crear juegos en HTML5 Canvas

domingo, 17 de mayo de 2015

Transición de animaciones

Ahora que tenemos la base de nuestro rompecabezas, es tiempo de ponerle una imagen. Salvo un nuevo detalle, ya sabemos cómo hacer esto, así que sólo lo remplazaremos rápidamente desde el comienzo. La imagen que usaremos para nuestro rompecabezas será la siguiente:

[Puzzle]

Comenzamos por declarar la variable para nuestra hoja de sprites:
        spritesheet = new Image();
Y asignar la imagen respectiva a esta variable:
        // Load assets
        spritesheet.src = 'assets/puzzle.png';
Utilizaremos la función "drawImageArea" del tema anterior, ajustando las dimensiones a las del rectángulo en lugar de las del círculo:
        drawImageArea: function (ctx, img, sx, sy, sw, sh) {
            if (img.width) {
                ctx.save();
                ctx.translate(this.x, this.y);
                //ctx.scale(this.scale, this.scale);
                //ctx.rotate(this.rotation * Math.PI / 180);
                ctx.drawImage(img, sx, sy, sw, sh, -this.width / 2, -this.height / 2, this.width, this.height);
                ctx.restore();
            } else {
                this.stroke(ctx);
            }
        }
Finalmente dibujaremos la imagen correspondiente a cada pieza del rompecabezas. En este momento, llega un nuevo reto a nosotros: ¿Cómo saber cual es la posición de la pieza que debe ser dibujada? Anteriormente hemos dibujado siempre todos los elementos de un tipo a la coordenada correspondiente de la hoja de sprites (Por ejemplo, todos los enemigos a la imagen en la coordenada 40,0), pero dado que cada pieza es una coordenada diferente en la hoja de sprites... ¿Cómo saber cual es la coordenada de la sección de la imagen correspondiente a cada pieza, teniendo únicamente el número de pieza correspondiente para identificarle?

Para resolver este problema, invertiremos la función con la que construimos nuestra rejilla. Sabemos que la rejilla tiene seis espacios a lo ancho, por tanto, sabiendo la posición de la pieza actual en el arreglo, podemos deducir que su posición en "x" es el módulo de seis de dicha posición en el arreglo. Por tanto, corresponde entonces a su coordenada "y", el valor entero resultante de esta posición en el arreglo, dividido entre estas seis posiciones por fila. Una vez conociendo estos valores, solo los multiplicamos por el ancho y alto de cada pieza correspondientemente, para obtener el área a dibujar de la imagen:
            var x = i % 6,
                y = ~~(i / 6);
            draggables[i].drawImageArea(ctx, spritesheet, x * 25, y * 25, 25, 25);
De esta forma, ya tendremos nuestro juego de rompecabezas con su respectiva imagen.

Aprovechemos ahora para dar un nuevo reto a estos rompecabezas; rotaremos las piezas cada 90 grados, y únicamente podremos poner la pieza del rompecabezas en su posición, si esta tiene su rotación correcta. Para ello, no olvides agregar la variable 'rotation' en nuestra función 'Rectangle2D':
        rotation: 0,
Al momento de crear un nuevo arrastrable, lo almacenaremos en una variable temporal "draggable" para asignarle al azar una rotación cada 90 grados disponibles:
                draggable = new Rectangle2D(random(canvas.width), random(canvas.height), 25, 25, false);
                draggable.rotation = random(4) * 90;
                draggables.push(draggable);
Y modificaremos el ajuste a la rejilla, de tal forma que la pieza se ajuste únicamente si además de estar en la posición correcta, la rotación de esta sea igual a cero, su rotación original:
                if (grid[dragging].contains(pointer) && draggables[dragging].rotation === 0) {
Para rotar la pieza, daremos un toque encima de ella. Almacenar temporalmente la posición del ratón al hacer clic para comparar si es la misma al soltarlo sería la forma más práctica de hacer esta tarea, pero en mi experiencia, he descubierto que ocurre con cierta frecuencia que al soltar el clic, el ratón se mueve ligeramente, lo que podría afectar un análisis de posiciones tan preciso. Por ello, lo que recomiendo yo, es crear un rectángulo que almacene una pequeña área que podría considerarse como posible intención de toque:
        tapArea = null,
En la función "init" asignamos a esta variable un rectángulo de 6 pixeles de alto por 6 pixeles de ancho, lo que nos da un margen de 3 pixeles a cada lado para esos pequeños arrastres accidentales al solar el botón del ratón:
        // Create tap area
        tapArea = new Rectangle2D(0, 0, 6, 6, false);
Para asegurarnos que el área de toque esté funcionando de forma correcta, dibujaremos su posición por ahora en pantalla:
        // Debug tap area position
        ctx.strokeStyle = '#0f0';
        tapArea.stroke(ctx);
Ahora sí, cada vez que presionemos el botón del ratón, posicionaremos el área de toque en la misma coordenada:
            // Position tap area here
            tapArea.x = pointer.x;
            tapArea.y = pointer.y;
Y al soltar el botón del ratón, si la posición sigue estando dentro del área de toque que definimos, rotaremos la pieza noventa grados más. Si la rotación es mayor o igual a 360, ajustaremos su valor para manejar únicamente valores en el rango de 0 a 359:
                // Rotate puzzle piece
                if (tapArea.contains(pointer)) {
                    draggables[dragging].rotation += 90;
                    if (draggables[dragging].rotation >= 360) {
                        draggables[dragging].rotation -= 360;
                    }
                }
Con esto podremos rotar las piezas de nuestro rompecabezas, y resolverlo con un nuevo nivel de dificultad.

Ahora, dado que la rotación de la pieza ocurre de inmediato, es posible que muchos jugadores no comprendan que dicha pieza acaba de rotar. La forma más práctica de darle a entender al usuario esta acción, es hacer una animación transición entre los dos estados. Para crear una transición, no se requiere mas que otra variable que contenga la diferencia entre el estado inicial y el estado final como se desee representar al usuario. Para representar la transición de la rotación de cada pieza, agregaremos a nuestro rectángulo dicha variable para asignar su valor correspondiente:
        rotationTransition: 0,
Modificaremos ligeramente la función "drawImageArea" para dibujar la rotación de la pieza como su rotación real, más la transición de dicho valor:
                ctx.rotate((this.rotation + this.rotationTransition) * Math.PI / 180);
Al soltar el botón del ratón, además de asignar su nueva rotación, asignaremos también a la transición 90 grados menos de su valor actual, para representar dicha animación de forma visual:
                    draggables[dragging].rotationTransition -= 90;
Finalmente, para animar la transición, si el valor de dicha transición es menor a cero, sumamos su valor en 5 cada ciclo dentro de la función "act":
        // Animate pieces rotation
        for (i = 0, l = draggables.length; i < l; i += 1) {
            if (draggables[i].rotationTransition < 0) {
                draggables[i].rotationTransition += deltaTime * 360;
                if (draggables[i].rotationTransition > 0) {
                    draggables[i].rotationTransition = 0;
                }
            }
        }
Con esto nuestro rompecabezas está terminado, entregando además al usuario una respuesta visual de las acciones de la rotación de piezas al hacer clic sobre ellas. ¡Felicidades! ¿Qué tan hábil eres ahora para resolver este rompecabezas?

Código final:

[Canvas not supported by your browser]
/*jslint bitwise: true, es5: true */
(function (window, undefined) {
    'use strict';
    var canvas = null,
        ctx = null,
        lastUpdate = 0,
        lastPress = null,
        lastRelease = null,
        mouse = {x: 0, y: 0},
        pointer = {x: 0, y: 0},
        dragging = null,
        tapArea = null,
        draggables = [],
        grid = [],
        isFinished = false,
        i = 0,
        l = 0,
        spritesheet = new Image();
    
    function Rectangle2D(x, y, width, height, createFromTopLeft) {
        this.width = (width === undefined) ? 0 : width;
        this.height = (height === undefined) ? this.width : height;
        if (createFromTopLeft) {
            this.left = (x === undefined) ? 0 : x;
            this.top = (y === undefined) ? 0 : y;
        } else {
            this.x = (x === undefined) ? 0 : x;
            this.y = (y === undefined) ? 0 : y;
        }
    }
    
    Rectangle2D.prototype = {
        left: 0,
        top: 0,
        width: 0,
        height: 0,
        rotation: 0,
        rotationTransition: 0,
        
        get x() {
            return this.left + this.width / 2;
        },
        set x(value) {
            this.left = value - this.width / 2;
        },
        
        get y() {
            return this.top + this.height / 2;
        },
        set y(value) {
            this.top = value - this.height / 2;
        },
        
        get right() {
            return this.left + this.width;
        },
        set right(value) {
            this.left = value - this.width;
        },
        
        get bottom() {
            return this.top + this.height;
        },
        set bottom(value) {
            this.top = value - this.height;
        },
        
        contains: function (rect) {
            if (rect !== undefined) {
                return (this.left < (rect.left || rect.x) &&
                    this.right > (rect.right || rect.x) &&
                    this.top < (rect.top || rect.y) &&
                    this.bottom > (rect.bottom || rect.y));
            }
        },
        
        intersects: function (rect) {
            if (rect !== undefined) {
                return (this.left < rect.right &&
                    this.right > rect.left &&
                    this.top < rect.bottom &&
                    this.bottom > rect.top);
            }
        },
        
        fill: function (ctx) {
            if (ctx !== undefined) {
                ctx.fillRect(this.left, this.top, this.width, this.height);
            }
        },
        
        stroke: function (ctx) {
            if (ctx !== undefined) {
                ctx.strokeRect(this.left, this.top, this.width, this.height);
            }
        },
        
        drawImageArea: function (ctx, img, sx, sy, sw, sh) {
            if (img.width) {
                ctx.save();
                ctx.translate(this.x, this.y);
                //ctx.scale(this.scale, this.scale);
                ctx.rotate((this.rotation + this.rotationTransition) * Math.PI / 180);
                ctx.drawImage(img, sx, sy, sw, sh, -this.width / 2, -this.height / 2, this.width, this.height);
                ctx.restore();
            } else {
                this.stroke(ctx);
            }
        }
    };
    
    function enableInputs() {
        document.addEventListener('mousemove', function (evt) {
            mouse.x = evt.pageX - canvas.offsetLeft;
            mouse.y = evt.pageY - canvas.offsetTop;
        }, false);
        
        document.addEventListener('mouseup', function (evt) {
            lastRelease = evt.which;
        }, false);
        
        canvas.addEventListener('mousedown', function (evt) {
            evt.preventDefault();
            lastPress = evt.which;
        }, false);
        
        canvas.addEventListener('touchmove', function (evt) {
            evt.preventDefault();
            var t = evt.targetTouches;
            mouse.x = t[0].pageX - canvas.offsetLeft;
            mouse.y = t[0].pageY - canvas.offsetTop;
        }, false);
        
        canvas.addEventListener('touchstart', function (evt) {
            evt.preventDefault();
            lastPress = 1;
            var t = evt.targetTouches;
            mouse.x = t[0].pageX - canvas.offsetLeft;
            mouse.y = t[0].pageY - canvas.offsetTop;
        }, false);
        
        canvas.addEventListener('touchend', function (evt) {
            lastRelease = 1;
        }, false);
        
        canvas.addEventListener('touchcancel', function (evt) {
            lastRelease = 1;
        }, false);
    }
    
    function random(max) {
        return ~~(Math.random() * max);
    }
    
    function getPuzzleSolved() {
        for (i = 0, l = grid.length; i < l; i += 1) {
            if (grid[i].x !== draggables[i].x || grid[i].y !== draggables[i].y) {
                return false;
            }
        }
        return true;
    }

    function paint(ctx) {
        // Clean canvas
        ctx.fillStyle = '#ccf';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        
        // Set default text properties
        ctx.textAlign = 'center';
        
        // Draw grid
        //ctx.fillStyle = '#999';
        ctx.strokeStyle = '#999';
        for (i = 0, l = grid.length; i < l; i += 1) {
            grid[i].stroke(ctx);
            //ctx.fillText(i, grid[i].x, grid[i].y);
        }
        
        // Draw rectangles
        ctx.strokeStyle = '#00f';
        for (i = draggables.length - 1; i > -1; i -= 1) {
            /*ctx.fillStyle = '#00f';
            draggables[i].fill(ctx);
            ctx.fillStyle = '#fff';
            ctx.fillText(i, draggables[i].x, draggables[i].y);*/
            var x = i % 6,
                y = ~~(i / 6);
            draggables[i].drawImageArea(ctx, spritesheet, x * 25, y * 25, 25, 25);
        }
        
        // Debug tap area position
        ctx.strokeStyle = '#0f0';
        tapArea.stroke(ctx);
        
        // Debug pointer position
        ctx.fillStyle = '#0f0';
        ctx.fillRect(pointer.x - 1, pointer.y - 1, 2, 2);
        
        // Is the game finished?
        ctx.fillStyle = '#fff';
        if (isFinished) {
            ctx.fillText('Well done!', canvas.width / 2, canvas.height / 2);
        }
        
        // Debug dragging rectangle
        //ctx.fillText('Dragging: ' + dragging, 0, 10);
    }
        
    function act(deltaTime) {
        // Set pointer to mouse
        pointer.x = mouse.x;
        pointer.y = mouse.y;
        
        // Limit pointer into canvas
        if (pointer.x < 0) {
            pointer.x = 0;
        }
        if (pointer.x > canvas.width) {
            pointer.x = canvas.width;
        }
        if (pointer.y < 0) {
            pointer.y = 0;
        }
        if (pointer.y > canvas.height) {
            pointer.y = canvas.height;
        }
        
        // Animate pieces rotation
        for (i = 0, l = draggables.length; i < l; i += 1) {
            if (draggables[i].rotationTransition < 0) {
                draggables[i].rotationTransition += deltaTime * 360;
                if (draggables[i].rotationTransition > 0) {
                    draggables[i].rotationTransition = 0;
                }
            }
        }
        
        if (lastPress === 1) {
            // Check for current dragging rectangle
            for (i = 0, l = draggables.length; i < l; i += 1) {
                if (draggables[i].contains(pointer)) {
                    dragging = i;
                    isFinished = false;
                    break;
                }
            }
            
            // Position tap area here
            tapArea.x = pointer.x;
            tapArea.y = pointer.y;
        }
        
        if (dragging !== null) {
            // Move current dragging rectangle
            draggables[dragging].x = pointer.x;
            draggables[dragging].y = pointer.y;
            
            if (lastRelease === 1) {
                // Rotate puzzle piece
                if (tapArea.contains(pointer)) {
                    draggables[dragging].rotationTransition -= 90;
                    draggables[dragging].rotation += 90;
                    if (draggables[dragging].rotation >= 360) {
                        draggables[dragging].rotation -= 360;
                    }
                }

                // Snap draggable intro grid
                if (grid[dragging].contains(pointer) && draggables[dragging].rotation === 0) {
                    draggables[dragging].x = grid[dragging].x;
                    draggables[dragging].y = grid[dragging].y;
                    isFinished = getPuzzleSolved();
                }

                // Release current dragging rectangle
                dragging = null;
            }
        }
    }

    function run() {
        window.requestAnimationFrame(run);
        
        var now = Date.now(),
            deltaTime = (now - lastUpdate) / 1000;
        if (deltaTime > 1) {
            deltaTime = 0;
        }
        lastUpdate = now;
        
        act(deltaTime);
        paint(ctx);
        
        lastPress = null;
        lastRelease = null;
    }
    
    function init() {
        // Get canvas and context
        canvas = document.getElementById('canvas');
        ctx = canvas.getContext('2d');
        canvas.width = 200;
        canvas.height = 300;
        
        // Load assets
        spritesheet.src = 'assets/puzzle.png';
        
        // Create tap area
        tapArea = new Rectangle2D(0, 0, 6, 6, false);
        
        // Create grid and draggables
        var x = 0,
            y = 0,
            draggable = null;
        for (y = 0; y < 4; y += 1) {
            for (x = 0; x < 6; x += 1) {
                grid.push(new Rectangle2D(x * 25 + 25, y * 25 + 100, 25, 25, true));
                draggable = new Rectangle2D(random(canvas.width), random(canvas.height), 25, 25, false);
                draggable.rotation = random(4) * 90;
                draggables.push(draggable);
            }
        }
        
        // Start game
        enableInputs();
        run();
    }
    
    window.addEventListener('load', init, false);
}(window));

No hay comentarios.:

Publicar un comentario