> Manuales > Taller de HTML5

Cómo escribir un juego BrikBlock con los elementos SVG y Canvas de HTML5.

El starter y la solución completa las puedes encontrar aquí.

Resumen

  1. Introducción
  2. Requisitos previos
  3. Preparación del fondo
  4. Preparación del juego
  5. Conclusión

Introducción

En este tutorial trataremos de desvelar algunas de las características del desarrollo de gráficos con los elementos SVG y Canvas (que son dos tecnologías destacadas de HTML5)

Para ello vamos a programar juntos un juego rompe-ladrillos (también conocido como Arkanoid o Blockout en el ambiente). Estará compuesto de un fondo animado (que haremos utilizando el Canvas) y con SVG crearemos los ladrillos, la bola y la raqueta.

La versión final la puedes ver en: http://www.catuhe.com/ms/en/index.htm

Requisitos previos

Preparación del fondo

El fondo es solamente una excusa para utilizar un canvas. Nos va a permitir dibujar pixels en un área concreta. Por eso vamos a utilizarlo para dibujar un “agujero de gusano” espacial (¡ah, como me gusta Stargate!). Los usuarios tendrán la posibilidad de mostrarlo en pantalla o no con el botón de Modo (Mode):

Como puedes ver, vamos a añadirle un contador en la esquina superior derecha (solo para demostrar la potencia de los gráficos acelerados por hardware )

Diseño de la página HTML5

Empecemos con el archivo index.htm, vamos a añadirle el canvas como un elemento hijo del div llamado “gameZone”:

1. <canvas id="backgroundCanvas">
2. Tu navegador no soporta HTML5. Instala Internet Explorer 9 :
3. <br />
4. <a href="http://windows.microsoft.com/en-US/internet-explorer/products/ie/home?ocid=ie9_bow_Bing&WT.srch=1&mtag=SearBing">
5. http://windows.microsoft.com/en-US/internet-explorer/products/ie/home?ocid=ie9_bow_Bing&WT.srch=1&mtag=SearBing</a>
6. </canvas>

Incorporar el código Javascript

El fondo se maneja desde el archivo background.js (¡qué sorpresa!). Así que tenemos que registrarlo dentro de index.htm. Justo antes del cierre de la etiqueta , añadiremos el código siguiente:

1. <script type="text/javascript" src="background.js"></script>

Configuración de constantes

Antes que nada, necesitamos unas constantes para controlar la presentación en pantalla:

1. var circlesCount = 100; // Número de círculos utilizados por el agujero de gusano
2. var offsetX = 70; // offset del centro del agujero (X)
3. var offsetY = 40; // offset del centro del agujero (Y)
4. var maxDepth = 1.5; // Distancia máxima para un círculo
5. var circleDiameter = 10.0; // Diámetro del círculo
6. var depthSpeed = 0.001; // Velocidad del círculo
7. var angleSpeed = 0.05; // Velocidad angular de rotación del círculo

Por supuesto, puedes modificar estas constantes si quieres cambiar el aspecto del agujero de gusano.

Creación de elementos

También necesitamos guardar referencias a los principales elementos de la página HTML:

1. var canvas = document.getElementById("backgroundCanvas");
2. var context = canvas.getContext("2d");
3. var stats = document.getElementById("stats");

¿Cómo dibujamos un círculo?

El agujero de gusano consiste únicamente en una secuencia de círculos con distintas posiciones y tamaños. Para dibujarlo utilizaremos la función circle que se basa en valores de profundidad, ángulo e intensidad (el color de base).

1. function Circle(initialDepth, initialAngle, intensity) {
2. }

El ángulo y la intensidad son privados, pero la profundidad es público, para que el agujero de gusano pueda cambiarla.

1. function Circle(initialDepth, initialAngle, intensity) {
2.
3. var angle = initialAngle;
4. this.depth = initialDepth;
5. var color = intensity;
6. }

También necesitamos una función draw pública para dibujar el círculo y actualizar los valores de profundidad y ángulo. Por eso tenemos que definir el punto donde se va a dibujar el círculo. Para ello definimos dos variables (x e y):

1. var x = offsetX * Math.cos(angle);
2. var y = offsetY * Math.sin(angle);

Dado que x e y son coordenadas del espacio, necesitamos proyectarlas en la pantalla:

1. function perspective(fov, aspectRatio, x, y) {
2. var yScale = Math.pow(Math.tan(fov / 2.0), -1);
3. var xScale = yScale / aspectRatio;
4.
5. var M11 = xScale;
6. var M22 = yScale;
7.
8. var outx = x * M11 + canvas.width / 2.0;
9. var outy = y * M22 + canvas.height / 2.0;
10.
11. return { x: outx, y: outy };
12. }

Así, la posición final del círculo se calcula con el código siguiente::

1. var x = offsetX * Math.cos(angle);
2. var y = offsetY * Math.sin(angle);
3.
4. var project = perspective(0.9, canvas.width / canvas.height, x, y);
5. var diameter = circleDiameter / this.depth;
6.
7. var ploX = project.x - diameter / 2.0;
8. var ploY = project.y - diameter / 2.0;

Y con esta posición ya podemos dibujar el círculo de manera muy sencilla:

1. context.beginPath();
2. context.arc(ploX, ploY, diameter, 0, 2 * Math.PI, false);
3. context.closePath();
4.
5. var opacity = 1.0 - this.depth / maxDepth;
6. context.strokeStyle = "rgba(" + color + "," + color + "," + color + "," + opacity + ")";
7. context.lineWidth = 4;
8.
9. context.stroke();

Como puedes ver, el círculo es más opaco cuanto más cercano.

Y finalmente:

1. function Circle(initialDepth, initialAngle, intensity) {
2. var angle = initialAngle;
3. this.depth = initialDepth;
4. var color = intensity;
5.
6. this.draw = function () {
7. var x = offsetX * Math.cos(angle);
8. var y = offsetY * Math.sin(angle);
9.
10. var project = perspective(0.9, canvas.width / canvas.height, x, y);
11. var diameter = circleDiameter / this.depth;
12.
13. var ploX = project.x - diameter / 2.0;
14. var ploY = project.y - diameter / 2.0;
15.
16. context.beginPath();
17. context.arc(ploX, ploY, diameter, 0, 2 * Math.PI, false);
18. context.closePath();
19.
20. var opacity = 1.0 - this.depth / maxDepth;
21. context.strokeStyle = "rgba(" + color + "," + color + "," + color + "," + opacity + ")";
22. context.lineWidth = 4;
23.
24. context.stroke();
25.
26. this.depth -= depthSpeed;
27. angle += angleSpeed;
28.
29. if (this.depth < 0) {
30. this.depth = maxDepth + this.depth;
31. }
32. };
33. };

Inicialización

Con nuestra función circle() ya podemos crear un array de círculos que vamos a inicializar cada vez más cerca de nosotros con un ligero desplazamiento del ángulo en cada iteración:

1. // Initialization
2. var circles = [];
3.
4. var angle = Math.random() * Math.PI * 2.0;
5.
6. var depth = maxDepth;
7. var depthStep = maxDepth / circlesCount;
8. var angleStep = (Math.PI * 2.0) / circlesCount;
9. for (var index = 0; index < circlesCount; index++) {
10. circles[index] = new Circle(depth, angle, index % 5 == 0 ? 200 : 255);
11.
12. depth -= depthStep;
13. angle -= angleStep;
14. }

Cálculo de Frames por Segundo (FPS)

Podemos calcular los frames por segundo midiendo la cantidad de tiempo entre dos llamadas a una función dada. En nuestro caso, la función se llamará computeFPS. Nos guardará las últimas 60 mediciones y calculará el promedio para producir el resultado deseado:

1. // FPS
2. var previous = [];
3. function computeFPS() {
4. if (previous.length > 60) {
5. previous.splice(0, 1);
6. }
7. var start = (new Date).getTime();
8. previous.push(start);
9. var sum = 0;
10.
11. for (var id = 0; id < previous.length - 1; id++) {
12. sum += previous[id + 1] - previous[id];
13. }
14.
15. var diff = 1000.0 / (sum / previous.length);
16.
17. stats.innerHTML = diff.toFixed() + " fps";
18. }

Dibujo y animaciones

El canvas es una herramienta de modo directo. Esto quiere decir que tenemos que reproducir todo el contenido del canvas cada vez que queramos cambiar algo.

Y lo primero que hay que hacer es borrar todo el contenido antes de dibujar cada frame. La mejor solución para ello es utilizar clearRect:

1. // Drawing & Animation
2. function clearCanvas() {
3. context.clearRect(0, 0, canvas.width, canvas.height);
4. }

Y el código completo para dibujar el agujero de gusano quedaría así:

1. function wormHole() {
2. computeFPS();
3. canvas.width = window.innerWidth;
4. canvas.height = window.innerHeight - 130 - 40;
5. clearCanvas();
6. for (var index = 0; index < circlesCount; index++) {
7. circles[index].draw();
8. }
9.
10. circles.sort(function (a, b) {
11. if (a.depth > b.depth)
12. return -1;
13. if (a.depth < b.depth)
14. return 1;
15. return 0;
16. });
17. }

El código de clasificación se utiliza para evitar que los círculos se superpongan.

Preparación del botón de modo

Para terminar con el fondo, necesitamos colocar un botón de modo que permita mostrar u ocultar el fondo mismo:

1.
2. var wormHoleIntervalID = -1;
3.
4. function startWormHole() {
5. if (wormHoleIntervalID > -1)
6. clearInterval(wormHoleIntervalID);
7.
8. wormHoleIntervalID = setInterval(wormHole, 16);
9.
10. document.getElementById("wormHole").onclick = stopWormHole;
11. document.getElementById("wormHole").innerHTML = "Standard Mode";
12. }
13.
14. function stopWormHole() {
15. if (wormHoleIntervalID > -1)
16. clearInterval(wormHoleIntervalID);
17.
18. clearCanvas();
19. document.getElementById("wormHole").onclick = startWormHole;
20. document.getElementById("wormHole").innerHTML = "Wormhole Mode";
21. }
22.
23. stopWormHole();

Preparación del juego

Para simplificar un poco este tutorial, el código de manejo del ratón ya está hecho. Puedes encontrar todo lo necesario en el archivo mouse.js.

Añadir el archivo Javascript del juego

El fondo lo manejamos mediante el archivo game.js. Tenemos que declararlo dentro de nuestra página index.htm. Así que antes del cierre de la etiqueta </body> añadimos esto:

1. <script type="text/javascript" src="game.js"></script>

Actualización de la página HTML5

El juego utilizará SVG (iniciales de Gráficos Vectoriales Escalables) para dibujar en pantalla los ladrillos, la raqueta y la bola. El SVG es una herramienta en modo “retenido”. No necesitamos redibujar los elementos completamente cada vez que queramos moverlos o modificar su aspecto.

Para añadir un SVG a nuestra página solo tenemos que insertar el código siguiente (después del canvas):

1. <svg id="svgRoot">
2. <circle cx="100" cy="100" r="10" id="ball" />
3. <rect id="pad" height="15px" width="150px" x="200" y="200" rx="10" ry="20"/>
4. </svg>

Como puedes ver, el SVG empieza con dos objetos ya definidos: un círculo para la pelota y un rectángulo para la raqueta.

Definición de constantes y variables

En el archivo game.js vamos a empezar añadiendo algunas variables:

1. // Elementos necesarios
2. var pad = document.getElementById("pad");
3. var ball = document.getElementById("ball");
4. var svg = document.getElementById("svgRoot");
5. var message = document.getElementById("message");

La pelota queda definida por los valores:

1. // Pelota
2. var ballRadius = ball.r.baseVal.value;
3. var ballX;
4. var ballY;
5. var previousBallPosition = { x: 0, y: 0 };
6. var ballDirectionX;
7. var ballDirectionY;
8. var ballSpeed = 10;

La raqueta queda definida por los valores de:

1. // Raqueta
2. var padWidth = pad.width.baseVal.value;
3. var padHeight = pad.height.baseVal.value;
4. var padX;
5. var padY;
6. var padSpeed = 0;
7. var inertia = 0.80;

Los ladrillos se guardan en un array y se definen mediante estos valores:

1. // Ladrillos
2. var bricks = [];
3. var destroyedBricksCount;
4. var brickWidth = 50;
5. var brickHeight = 20;
6. var bricksRows = 5;
7. var bricksCols = 20;
8. var bricksMargin = 15;
9. var bricksTop = 20;

Y finalmente necesitamos los límites del campo de juego y una fecha de inicio para calcular la duración de la sesión.

1. // Otros valores.
2. var minX = ballRadius;
3. var minY = ballRadius;
4. var maxX;
5. var maxY;
6. var startDate;

Manejo de los ladrillos

Para crear un ladrillo vamos a necesitar una función que añada un nuevo elemento a la raíz de svg. Además configurará cada ladrillo con la información necesaria:

1. var rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
2. svg.appendChild(rect);
3.
4. rect.setAttribute("width", brickWidth);
5. rect.setAttribute("height", brickHeight);
6.
7. // Color verde aleatorio
8. var chars = "456789abcdef";
9. var color = "";
10. for (var i = 0; i < 2; i++) {
11. var rnd = Math.floor(chars.length * Math.random());
12. color += chars.charAt(rnd);
13. }
14. rect.setAttribute("fill", "#00" + color + "00");

La función brick contiene también una función drawAndCollide para mostrar un ladrillo y comprobar si choca con la pelota:

1. this.drawAndCollide = function () {
2. if (isDead)
3. return;
4. // Dibujo
5. rect.setAttribute("x", position.x);
6. rect.setAttribute("y", position.y);
7.
8. // Colisión
9. if (ballX + ballRadius < position.x || ballX - ballRadius > position.x + brickWidth)
10. return;
11.
12. if (ballY + ballRadius < position.y || ballY - ballRadius > position.y + brickHeight)
13. return;
14.
15. // Muere
16. this.remove();
17. isDead = true;
18. destroyedBricksCount++;
19.
20. // Redibuja la pelota
21. ballX = previousBallPosition.x;
22. ballY = previousBallPosition.y;
23.
24. ballDirectionY *= -1.0;
25. };

Por último, la función brick completa sería así:

1. // Función brick
2. function Brick(x, y) {
3. var isDead = false;
4. var position = { x: x, y: y };
5.
6. var rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
7. svg.appendChild(rect);
8.
9. rect.setAttribute("width", brickWidth);
10. rect.setAttribute("height", brickHeight);
11.
12. // Color verde aleatorio
13. var chars = "456789abcdef";
14. var color = "";
15. for (var i = 0; i < 2; i++) {
16. var rnd = Math.floor(chars.length * Math.random());
17. color += chars.charAt(rnd);
18. }
19. rect.setAttribute("fill", "#00" + color + "00");
20.
21. this.drawAndCollide = function () {
22. if (isDead)
23. return;
24. // Dibujo
25. rect.setAttribute("x", position.x);
26. rect.setAttribute("y", position.y);
27.
28. // Colisión
29. if (ballX + ballRadius < position.x || ballX - ballRadius > position.x + brickWidth)
30. return;
31.
32. if (ballY + ballRadius < position.y || ballY - ballRadius > position.y + brickHeight)
33. return;
34.
35. // Muere
36. this.remove();
37. isDead = true;
38. destroyedBricksCount++;
39.
40. // Redibuja la pelota
41. ballX = previousBallPosition.x;
42. ballY = previousBallPosition.y;
43.
44. ballDirectionY *= -1.0;
45. };
46.
47. // Destruye el ladrillo
48. this.remove = function () {
49. if (isDead)
50. return;
51. svg.removeChild(rect);
52. };
53. }

Colisiones con la raqueta y el fondo

La pelota también tiene funciones de colisión que manejan las colisiones con la raqueta y el fondo. Estas funciones tienen que modificar la dirección de la pelota cuando se detecta un choque.

1. // Colisiones
2. function collideWithWindow() {
3. if (ballX < minX) {
4. ballX = minX;
5. ballDirectionX *= -1.0;
6. }
7. else if (ballX > maxX) {
8. ballX = maxX;
9. ballDirectionX *= -1.0;
10. }
11.
12. if (ballY < minY) {
13. ballY = minY;
14. ballDirectionY *= -1.0;
15. }
16. else if (ballY > maxY) {
17. ballY = maxY;
18. ballDirectionY *= -1.0;
19. lost();
20. }
21. }
22.
23. function collideWithPad() {
24. if (ballX + ballRadius < padX || ballX - ballRadius > padX + padWidth)
25. return;
26.
27. if (ballY + ballRadius < padY)
28. return;
29.
30. ballX = previousBallPosition.x;
31. ballY = previousBallPosition.y;
32. ballDirectionY *= -1.0;
33.
34. var dist = ballX - (padX + padWidth / 2);
35.
36. ballDirectionX = 2.0 * dist / padWidth;
37.
38. var square = Math.sqrt(ballDirectionX * ballDirectionX + ballDirectionY * ballDirectionY);
39. ballDirectionX /= square;
40. ballDirectionY /= square;
41. }

collideWithWindow comprueba los límites del campo de juego, y collideWithPad comprueba os límites de la raqueta (nosotros añadimos un sutil cambio aquí: la velocidad horizontal de la pelota se calcula utilizando la distancia con respecto al centro de la raqueta).

Movimiento de la raqueta

La raqueta se controla con el ratón o con las teclas de flecha izquierda y derecha. La función movePad es la encargada de controlar el movimiento de la raqueta y también el valor de inercia:

1. // Movimiento de la raqueta
2. function movePad() {
3. padX += padSpeed;
4.
5. padSpeed *= inertia;
6.
7. if (padX < minX)
8. padX = minX;
9.
10. if (padX + padWidth > maxX)
11. padX = maxX - padWidth;
12. }

El código que se encarga de las entradas es muy sencillo:

1. registerMouseMove(document.getElementById("gameZone"), function (posx, posy, previousX, previousY) {
2. padSpeed += (posx - previousX) * 0.2;
3. });
4.
5. window.addEventListener('keydown', function (evt) {
6. switch (evt.keyCode) {
7. // flecha izquierda
8. case 37:
9. padSpeed -= 10;
10. break;
11. // Flecha derecha
12. case 39:
13. padSpeed += 10;
14. break;
15. }
16. }, true);

Bucle del juego

Antes de escribir el bucle del juego necesitamos una función que defina el tamaño del campo de juego. Llamaremos a esta función si hay que cambiar el tamaño de la ventana.

1. function checkWindow() {
2. maxX = window.innerWidth - minX;
3. maxY = window.innerHeight - 130 - 40 - minY;
4. padY = maxY - 30;
5. }

Y finalmente, el bucle del juego es el orquestador, que tienes aquí:

1. function gameLoop() {
2. movePad();
3.
4. // Movimientos
5. previousBallPosition.x = ballX;
6. previousBallPosition.y = ballY;
7. ballX += ballDirectionX * ballSpeed;
8. ballY += ballDirectionY * ballSpeed;
9.
10. // Colisiones
11. collideWithWindow();
12. collideWithPad();
13.
14. // ladrillos
15. for (var index = 0; index < bricks.length; index++) {
16. bricks[index].drawAndCollide();
17. }
18.
19. // pelota
20. ball.setAttribute("cx", ballX);
21. ball.setAttribute("cy", ballY);
22.
23. // Raqueta
24. pad.setAttribute("x", padX);
25. pad.setAttribute("y", padY);
26.
27. // Has ganado ?
28. if (destroyedBricksCount == bricks.length) {
29. win();
30. }
31. }

Inicialización y victoria

El primer paso de la inicialización consiste en crear los ladrillos:

1. function generateBricks() {
2. // Eliminar los anteriores
3. for (var index = 0; index < bricks.length; index++) {
4. bricks[index].remove();
5. }
6.
7. // Creamos los ladrillos nuevos
8. var brickID = 0;
9.
10. var offset = (window.innerWidth - bricksCols * (brickWidth + bricksMargin)) / 2.0;
11.
12. for (var x = 0; x < bricksCols; x++) {
13. for (var y = 0; y < bricksRows; y++) {
14. bricks[brickID++] = new Brick(offset + x * (brickWidth + bricksMargin), y * (brickHeight + bricksMargin) + bricksTop);
15. }
16. }
17. }

El paso siguiente será definir las variables que utilizaremos en el juego:

1. function initGame() {
2. message.style.visibility = "hidden";
3.
4. checkWindow();
5.
6. padX = (window.innerWidth - padWidth) / 2.0;
7.
8. ballX = window.innerWidth / 2.0;
9. ballY = maxY - 60;
10.
11. previousBallPosition.x = ballX;
12. previousBallPosition.y = ballY;
13.
14. padSpeed = 0;
15.
16. ballDirectionX = Math.random();
17. ballDirectionY = -1.0;
18.
19. generateBricks();
20. gameLoop();
21. }

Cada vez que el usuario cambie el tamaño de la ventana tendremos que reinicializarlo:

1. window.onresize = initGame;

Después tenemos que vincular un manejador de eventos al botón de “nueva partida”:

1. var gameIntervalID = -1;
2. function startGame() {
3. initGame();
4.
5. destroyedBricksCount = 0;
6.
7. if (gameIntervalID > -1)
8. clearInterval(gameIntervalID);
9.
10. startDate = (new Date()).getTime(); ;
11. gameIntervalID = setInterval(gameLoop, 16);
12. }
13.
14. document.getElementById("newGame").onclick = startGame;

Por último, añadimos dos funciones para controlar el inicio y la finalización del juego:

1. var gameIntervalID = -1;
2. function lost() {
3. clearInterval(gameIntervalID);
4. gameIntervalID = -1;
5.
6. message.innerHTML = "Game over !";
7. message.style.visibility = "visible";
8. }
9.
10. function win() {
11. clearInterval(gameIntervalID);
12. gameIntervalID = -1;
13.
14. var end = (new Date).getTime();
15.
16. message.innerHTML = "Victory ! (" + Math.round((end - startDate) / 1000) + "s)";
17. message.style.visibility = "visible";
18. }

Conclusión

¡Ya te has convertido en desarrollador de juegos!. Con la potencia de la aceleración de gráficos por hardware hemos desarrollado un jueguecito sencillo, ¡pero con efectos especiales muy interesantes!

Y ahora te toca a ti variar el juego para convertirlo en la última versión del blockbuster!

Si quieres saber más

David Catuhe

desarrollador evangelista de Microsoft France

Manual