Partager via


Trabajando en un Canvas con TypeScript (I)

THUMB

Una vez que hemos empezado a trabajar con TypeScript podemos empezar a ver lo mucho que nos ayuda en la programación orientada a objetos.
Un escenario en el que podemos trabajar fácilmente con orientación a objetos es en los juegos, y en este ejemplo veremos cómo crear nuestro primer juego en un canvas mediante TypeScript.

Estructura de archivos

Para este ejemplo he utilizado muy pocos archivos y una estructura sencilla que puedes copiar o mejorar según tu criterio.

image

Un archivo llamado index.html que va a estar vacío y es donde voy a incluir los archivos de JavaScript de mi juego.

  1: <!DOCTYPE html>
  2: <html>
  3: <head>
  4:     <title>Juego Typescript</title>
  5: </head>
  6: <body>
  7:  
  8:     <script src="ts/index.js"></script>
  9:     ...
  10:     ...
  11: </body>
  12: </html>
  13:  
  14:  

Un archivo index.ts en el que voy a inicializar el juego y en el que también podría crear una configuración específica si fuera necesario:

  1: /// <reference path="import.ts" />
  2:  
  3: module TSGame { 
  4:     window.onload = () => {
  5:         new Game();
  6:     }
  7: }
  8:  

Una carpeta llamada Models en la que pondré todas las clases necesarias para mi juego, por ejemplo: Canvas, Enemy, etc.

La carpeta Interfaces donde tendremos las interfaces que vayamos a necesitar utilizar en el proyecto.

Finalmente un archivo llamado import.ts que es el estándar de TypeScript en el que vamos a referenciar los archivos a utilizar en el proyecto de TS.

Nuestro juego

Para hacer un ejemplo sencillo, haremos un pequeño juego en canvas en el cual aparecen enemigos de forma aleatoria que hemos de esquivar.

Empezando el proyecto

Notas previas

Aunque muchas veces lo lógico es tener una configuración general del juego, para este ejemplo voy a configurar cada elemento en su misma clase, solo a modo de ejemplo.

También añadir que aunque podemos dar a cualquier propiedad el tipo que consideremos de HTML, muchas veces es mejor centrarse en una serie de tipos mínimos para nuestras propiedades. Yo suelo trabajar con todos los de typescript más el tipo Event y algún tipo simple.

Por ejemplo:

  1: // En vez de
  2: private canvas: HTMLCanvasElement;
  3:  
  4: // Usaré
  5: private canvas: any;

Canvas.ts

Esta clase va a gestionar la creación y configuración de nuestro canvas.

Inicializamos las propiedades necesarias de la clase:

  1: private canvas: any;
  2: private ctx: any;
  3: private width: number = 800;
  4: private height: number = 600;

Creamos un método simple que nos pinte el fondo:

  1: constructor() { 
  2:     this.canvas = document.createElement('canvas');
  3:     this.canvas.width = this.width;
  4:     this.canvas.height = this.height;
  5:     document.body.appendChild(this.canvas);
  6:  
  7:     this.ctx = this.canvas.getContext('2d');
  8:  
  9:     this.canvasStyling()
  10: }

Por último inicializamos todo en el constructor:

  1: constructor() { 
  2:     this.canvas = document.createElement('canvas');
  3:     this.canvas.width = this.size.width;
  4:     this.canvas.height = this.size.height;
  5:     document.body.appendChild(this.canvas);
  6:  
  7:     this.ctx = this.canvas.getContext('2d');
  8:  
  9:     this.canvasStyling()
  10: }

Ya tenemos un fabuloso canvas negro.

image

 

Personajes

Tenemos un elemento básico del que van a heredar los demás personajes.

  1: /// <reference path="../import.ts" />
  2:  
  3: module TSGame { 
  4:     'use strict';
  5:  
  6:     export class GameElement implements IGameElement { 
  7:  
  8:         public health: number;
  9:         public speed: number;
  10:         public color: string;
  11:         public image: any;
  12:         public x: number = 0;
  13:         public y: number = 0;
  14:         public size: number = 32;
  15:         public config;
  16:  
  17:         constructor() { 
  18:             this.health = this.config.health.max;
  19:             this.speed = this.config.speed.normal;
  20:             this.color = this.config.colors.normal;
  21:             this.image = new Image();
  22:             this.image.src = this.config.image;
  23:         }
  24:     }
  25: }

Previamente he creado una interfaz para la clase:

  1: /// <reference path="../import.ts" />
  2:  
  3: module TSGame { 
  4:     export interface IGameElement { 
  5:         health: number;
  6:         speed: number;
  7:         color: string;
  8:         image: any;
  9:         x: number;
  10:         y: number;
  11:         size: number;
  12:         config: {
  13:             health: { max: number; min: number; };
  14:             speed: { max: number; normal: number; min: number; };
  15:             colors: { normal: string; danger: string; bonus?: string; };
  16:             image: string;
  17:        };
  18:     }
  19: }

Gracias a esto, podemos crear las clases de Enemy de Hero y lo que queramos, de una manera bastante sencilla:

  1: /// <reference path="../import.ts" />
  2:  
  3: module TSGame { 
  4:     'use strict';
  5:  
  6:     export class Hero extends GameElement { 
  7:  
  8:         constructor() { 
  9:             this.config = {
  10:                 health: {
  11:                     max: 100,
  12:                     min: 0
  13:                 },
  14:  
  15:                 speed: {
  16:                     max: 20,
  17:                     normal: 10,
  18:                     min: 1
  19:                 },
  20:  
  21:                 colors: {
  22:                     normal: 'blue',
  23:                     danger: 'red',
  24:                     bonus: 'yellow'
  25:                 },
  26:  
  27:                 image: '../../images/hero.png'
  28:             }
  29:  
  30:             super();
  31:         }
  32:     }
  33: }
  1: /// <reference path="../import.ts" />
  2:  
  3: module TSGame { 
  4:     'use strict';
  5:  
  6:     export class Enemy extends GameElement { 
  7:         constructor() { 
  8:             this.config = {
  9:                 health: {
  10:                     max: 10,
  11:                     min: 0
  12:                 },
  13:  
  14:                 speed: {
  15:                     max: 5,
  16:                     normal: 5,
  17:                     min: 1
  18:                 },
  19:  
  20:                 colors: {
  21:                     normal: 'green',
  22:                     danger: 'red'
  23:                 },
  24:  
  25:                 image: '../../images/enemy.png'
  26:             }
  27:  
  28:             super();
  29:         }
  30:     }
  31: }

Para probar que todo está como esperamos, inicializamos la clase Game que luego modificaremos:

  1: /// <reference path="../import.ts" />
  2:  
  3: module TSGame { 
  4:     'use strict';
  5:  
  6:     export class Game { 
  7:  
  8:         private canvas: any;
  9:         private hero: Hero;
  10:  
  11:         constructor() { 
  12:             this.initialize();
  13:         }
  14:  
  15:         public initialize(): void { 
  16:             this.canvas = new Canvas();
  17:             this.hero = new Hero();
  18:             var enemy = new Enemy();
  19:             this.drawElement(this.hero, 100, 100);
  20:             this.drawElement(enemy, 200, 100);
  21:         }
  22:  
  23:         public drawElement(element: IGameElement, x: number, y: number) {
  24:             this.canvas.ctx.drawImage(element.image, x, y); 
  25:         }
  26:     }
  27: }

image

Movimiento de los personajes

Vamos a tener dos tipos de movimiento, uno mediante las teclas para Hero y uno aleatorio para Enemy.

Hero:

Nos interesan las teclas de las flechas, por lo que preparamos un enum con ellas:

  1: enum Keys { Left = 37, Up = 38, Right = 39, Down = 40 };

Para saber qué tecla se ha pulsado asociamos los eventos necesarios y nos guardamos las teclas activas:

  1: private bindEvents(): void { 
  2:     this.keysDown = {};
  3:  
  4:     addEventListener('keydown', e => { 
  5:         // arrows: 37, 38, 39, 40
  6:         if (e.keyCode >= Keys.Left && e.keyCode <= Keys.Down) {
  7:             this.keysDown[Keys[e.keyCode]] = true;
  8:         }
  9:     }, false);
  10:  
  11:     addEventListener('keyup', e => {
  12:         if (e.keyCode >= Keys.Left && e.keyCode <= Keys.Down) {
  13:             this.keysDown[Keys[e.keyCode]] = false;
  14:         }
  15:     }, false);
  16: }

Además, cada vez que pulsemos una tecla, vamos a hacer el movimiento correspondiente. Para ello creamos ya el gameloop (al ser un ejemplo usaremos requestAnimationFrame sin tener en cuenta la compatibilidad).

En update actualizamos la posición del personaje y en draw lo dibujamos:

  1: constructor() { 
  2:     this.initialize();
  3:     this.gameLoop();
  4: }
  5:  
  6: private gameLoop(): void { 
  7:     window.requestAnimationFrame(() => this.gameLoop());
  8:     this.update();
  9:     this.draw();
  10: }
  11:  
  12: private update(): void { 
  13:     var keys = this.hero.keysDown;
  14:     var moveUnits = this.hero.size * 1 / this.hero.speed + this.hero.speed;
  15:     if (keys.Left === true) { 
  16:         this.hero.x -= (this.hero.x > 0) ? moveUnits : 0;
  17:     }
  18:  
  19:     if (keys.Right === true) {
  20:         this.hero.x += (this.hero.x < this.canvas.width - this.hero.size) ? moveUnits : 0;
  21:     }
  22:  
  23:     if (keys.Down === true) {
  24:         this.hero.y += (this.hero.y < this.canvas.height - this.hero.size) ? moveUnits : 0;
  25:     }
  26:  
  27:     if (keys.Up === true) {
  28:         this.hero.y -= (this.hero.y > 0) ? moveUnits : 0;
  29:     }
  30: }
  31:  
  32: private draw(): void {
  33:     this.canvas.clearCanvas();
  34:     this.drawElement(this.hero);
  35: }

Y este sería el resultado obtenido:

task

Para terminar este primer artículo vamos a mostrar la vida que tiene nuestro protagonista, una barra de 100 que irá cambiando de color según nuestro estado. Para ello añadimos una última función en el método draw():

  1: private draw(): void {
  2:     this.canvas.clearCanvas();
  3:     this.drawElement(this.hero);
  4:     this.drawHealth(this.hero);
  5: }
  6:  
  7: public drawHealth(element: IGameElement): void {
  8:     this.canvas.ctx.beginPath();
  9:     this.canvas.ctx.rect(10, 10, element.health, 10);
  10:     this.canvas.ctx.fillStyle = element.color;
  11:     this.canvas.ctx.fill();
  12: }

En la siguiente parte vamos a crear los diferentes enemigos y a controlar la vida del personaje.

¿Habías probado alguna vez a utilizar TypeScript para trabajar en un canvas o un juego? ¿No te parece mucho más claro el código que tenemos? ¿Sabías que muchas de las librerías famosas para crear juegos en JavaScript son compatibles con TypeScript?

_____________________________

Quique Fernández

Technical Evangelist Intern

@CKGrafico

Comments

  • Anonymous
    February 10, 2015
    Hola, muy buena idea, gracias por el post! Personalmente cambiaría algunas cosillas en cuanto al estilo de código, de cara a la legibilidad y las características de TypeScript: if (keys.Right === true) -> if (keys.Right) ya es booleano, compararlo con true no aporta ningún valor. A las clases privadas les pondría un prefijo "", puesto que el resultado de TypeScript será ECMAScript donde, por convenio, algo privado se antepone con "" denotando que no deberá emplearse. En TypeScript tienes private que te lo impide, pero en ECMASript no. Adicionalmente hay validadores que te impiden utilizar underscores ("_") porque se entienden como de carácter privado. También trataría precisamente de beneficiarme del tipado que aporta TypeScript, comenzando por el canvas. Con esto garantizas que no te sales del estándar, y si lo haces (anteponiendo el casting (<any>this.canvas).miPropiedad) eres plenamente consciente porque te obliga el compilador. Por otro lado, la declaración de variables mediante "var" la haría siempre al comienzo de cada bloque de función, por el hoisting que realiza ECMAScript (no así con "let" de EcmaScript 6 Harmony). No hacerlo provoca la ilusión de que la variable se está definiendo en esa línea cuando en realidad se declara al comienzo de la función y se asigna en esa línea (similar a definir primero las propiedades y sus tipos y en otra sección sus inicializaciones): initialize(): void { // este método ya es público por defecto, y al no tener sentencias return, su tipo de salida es void por inferencia. Sin embargo por robustez en las propiedades públicas es mejor ponerlo explícitamente para garantizar que el contenido de la función devuelve el tipo esperado. Para métodos protegidos o privados yo lo omitiría por flexibilidad, aprovechando la inferencia de tipos de TypeScript. Si algo cambia a algo que no debería, el compilador chillaría. var enemy = new Enemy(); this.canvas = new Canvas(); this.hero = new Hero(); this.drawElement(this.hero, 100, 100); this.drawElement(enemy, 200, 100); } Un saludo!

  • Anonymous
    February 11, 2015
    Muchas gracias por tu aportación al post Sergio!