Contenidos
ESE Salvapantallas
¿Alguna vez te has preguntado cómo puedes hacer algo como el clásico salvapantallas del logo de DVD rebotando?
Yo tampoco, pero hoy te voy a mostrar algunos de los conceptos básicos de Programación Orientada a Objetos que usarías para algo como el desarrollo de videojuegos, para crear el efecto que estás viendo en pantalla (si no tienes JavaScript activado, te lo estás perdiendo).
Y podrías decir: “¿En qué me ayudaría esto?” Créeme, si te interesa el desarrollo de videojuegos esto podría interesarte.
Este será un proyecto muy pequeño, menos de 170 líneas, así que vamos a usar solo un archivo.
¡Por cierto, usaremos TypeScript! JavaScript está bien, pero para cualquier proyecto a gran escala, usar lenguajes tipados es imprescindible.
Empecemos con la lógica básica para esto:
Sabemos que si queremos mover un círculo por la pantalla necesitamos dos cosas:
Dirección y velocidad o fuerza que necesitamos aplicar al objeto para moverlo, así que empecemos creando nuestra primera clase:
class Character {
x: number;
y: number;
x_power: number;
y_power: number;
}
Observa que cada vez que la pelota rebota, la fuerza aplicada a ambos ejes cambia de forma individual, así que con x e y estamos rastreando las coordenadas actuales del Character, y con x_power y y_power estamos rastreando la fuerza que se les aplica en cada frame.
Ahora que hemos introducido la clase Character y sus propiedades principales—x, y, x_power y y_power—demos el siguiente paso y hagamos esto más dinámico.
En este momento, x_power y y_power son solo números. Eso funciona bien, pero en desarrollo de videojuegos, a menudo necesitamos más flexibilidad. Por ejemplo, ¿qué pasa si queremos aumentar la fuerza aplicada a estas propiedades? Podríamos hacerlo incrementando manualmente la propiedad, o podemos crear una clase Power, que hará más fácil crear lógica reutilizable.
Vamos a crear una clase Power
Podemos encapsular el comportamiento de x_power y y_power en una clase. Esta es una de las ideas clave detrás de la Programación Orientada a Objetos: agrupar datos y comportamientos relacionados en una sola unidad.
class Power {
val = 0;
constructor() {
this.update_power();
}
update_power() {
this.val = Math.random() * (7 - 3) + 3; // random between 3 and 7
}
}
Ahora tenemos que volver a la clase de Character y añadir lo siguiente:
class Character {
x: number;
y: number;
x_power: Power;
y_power: Power;
constructor() {
this.x = Math.random() * window.innerWidth;
this.y = Math.random() * window.innerHeight;
this.x_power = new Power();
this.y_power = new Power();
}
}
Ya no estamos simplemente almacenando un número—estamos usando un mini-motor para la potencia de cada eje. Esto abre la puerta a comportamientos más interesantes.
Observa que en el constructor de la clase Character estamos asignando un valor aleatorio a x e y, y estamos usando el mismo método Math.random() para ambos. Cambiemos eso:
Tenemos dos alternativas: podemos crear una clase llamada Utils que almacene un método estático dentro de ella, algo así:
class Utils {
static random_between(min:number, max: number): number {
return Math.random() * (max - min) + min;
}
}
//Let's use it
Utils.random_between(1,10);
Esa es una forma de hacerlo: estamos encapsulando un método que podría ser utilizado en muchos lugares diferentes dentro de una clase que es autoexplicativa.
La palabra clave static se puede aplicar a cualquier función o variable que queramos que esté disponible a nivel de clase, así que no necesitamos instanciar la clase antes de usarla.
Eso está bien, pero en este caso prefiero escribir simplemente una función global, ya que no voy a agregar más funciones utilitarias a este proyecto:
function random_between(min: number, max: number): number {
return Math.random() * (max - min) + min;
}
Agregando Dirección
Ahora necesitamos decidir en qué dirección se mueve la pelota/personaje. Usaremos enums para mayor claridad:
enum XDirection {
LEFT,
RIGHT,
}
enum YDirection {
UP,
DOWN,
}
Con esto, ahora podemos construir la lógica que dice: “si voy a la izquierda, resto a x; si voy a la derecha, sumo a x”—lo mismo para y.
Manejo del Movimiento
Ahora que tenemos la fuerza y la dirección, necesitamos un método que mueva la pelota basado en eso.
private handle_x() {
if (this.x_dir === XDirection.LEFT) {
this.x += this.x_power.val;
} else {
this.x -= this.x_power.val;
}
}
private handle_y() {
if (this.y_dir === YDirection.DOWN) {
this.y += this.y_power.val;
} else {
this.y -= this.y_power.val;
}
}
Esto es un poco engorroso, estamos usando exactamente la misma función dos veces, con valores diferentes; sin embargo, lo dejaré así, ¡vamos rápido!
También queremos cambiar la dirección cuando el personaje golpea los bordes del canvas, y ya que estamos, ¡quizás cambiar el color también! (Como ese viejo logo de DVD rebotando.)
Generando Color
Vamos a generar un color aleatorio cada vez que el personaje rebote:
private get_new_color() {
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
this.c_c = `rgb(${r},${g},${b})`;
}
Llamaremos a esto en nuestra lógica de rebote para darle ese satisfactorio cambio de color:
private update_direction() {
this.x_power.update_power();
this.y_power.update_power();
this.get_new_color();
}
Probablemente habrás notado la palabra clave private antes de casi todas las funciones o propiedades dentro de la clase Character. Esto es para mantener claridad y control; solo queremos exponer aquellas funciones o valores que deben ser accedidos desde fuera de la clase.
Bucle y Dibujo
Ahora agregamos un método loop que se llama en cada frame. Aquí es donde ocurre la magia:
loop(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) {
if (this.y > canvas.height) {
this.y_dir = YDirection.UP;
this.update_direction();
}
if (this.y < 0) {
this.y_dir = YDirection.DOWN;
this.update_direction();
}
if (this.x > canvas.width) {
this.x_dir = XDirection.RIGHT;
this.update_direction();
}
if (this.x < 0) {
this.x_dir = XDirection.LEFT;
this.update_direction();
}
this.handle_x();
this.handle_y();
this.draw(ctx);
}
Y dibujar es es bastante simple:
private draw(ctx: CanvasRenderingContext2D) {
ctx.fillStyle = this.c_c;
ctx.beginPath();
ctx.arc(this.x, this.y, 30, 0, Math.PI * 2);
ctx.fill();
}
Juntándolo Todo
Finalmente, en tu función principal:
export default function () {
const canvas = document.querySelector("#canvas") as HTMLCanvasElement;
if (!canvas) return;
function resize_canvas() {
canvas.height = window.innerHeight;
canvas.width = window.innerWidth;
}
resize_canvas();
window.addEventListener("resize", resize_canvas);
const character = new Character(window);
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
function loop() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
character.loop(canvas, ctx);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
}
Como puedes ver, agregué una función para obtener los datos actualizados del tamaño de la ventana en caso de que alguien la cambie de tamaño, ahora es (probablemente) a prueba de balas.
Ahora veamos el script completo:
//Constants
const MIN_COLOR_VAL = 0;
const MAX_COLOR_VAL = 255;
//Utility
function random_between(min: number, max: number): number {
return Math.random() * (max - min) + min;
}
//Types
enum XDirection {
LEFT,
RIGHT,
}
enum YDirection {
UP,
DOWN,
}
//Classes
class Character {
private x: number;
private y: number;
private c_c: string = "";
private x_power: Power;
private y_power: Power;
private x_dir = XDirection.LEFT;
private y_dir = YDirection.DOWN;
constructor(window: Window) {
this.x = random_between(0, window.innerWidth);
this.y = random_between(0, window.innerHeight);
this.get_new_color();
this.x_power = new Power();
this.y_power = new Power();
}
private add(x: number, y: number) {
return x + y;
}
private subtract(x: number, y: number) {
return x - y;
}
private handle_x() {
if (this.x_dir == XDirection.LEFT) {
this.x = this.add(this.x, this.x_power.val);
} else {
this.x = this.subtract(this.x, this.x_power.val);
}
}
private handle_y() {
if (this.y_dir == YDirection.DOWN) {
this.y = this.add(this.y, this.y_power.val);
} else {
this.y = this.subtract(this.y, this.y_power.val);
}
}
private get_new_color() {
const r = random_between(MIN_COLOR_VAL, MAX_COLOR_VAL);
const g = random_between(MIN_COLOR_VAL, MAX_COLOR_VAL);
const b = random_between(MIN_COLOR_VAL, MAX_COLOR_VAL);
this.c_c = `rgb(${r},${g},${b})`;
}
private update_direction() {
this.x_power.update_power();
this.y_power.update_power();
this.get_new_color();
}
loop(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) {
if (this.y > canvas.height) {
this.y_dir = YDirection.UP;
this.update_direction();
}
if (this.y < 0) {
this.y_dir = YDirection.DOWN;
this.update_direction();
}
if (this.x > canvas.width) {
this.x_dir = XDirection.RIGHT;
this.update_direction();
}
if (this.x < 0) {
this.x_dir = XDirection.LEFT;
this.update_direction();
}
this.handle_x();
this.handle_y();
this.draw(ctx);
}
private draw(ctx: CanvasRenderingContext2D) {
ctx.fillStyle = this.c_c;
ctx.beginPath();
ctx.arc(this.x, this.y, 30, 0, Math.PI * 2);
ctx.fill();
}
}
class Power {
val = 0;
constructor() {
this.update_power();
}
update_power() {
this.val = random_between(3, 7);
}
}
export default function () {
//HTMLElements
const canvas = document.querySelector("#canvas") as HTMLCanvasElement;
if (!canvas) return null;
//Resize
function resize_canvas() {
canvas.height = window.innerHeight;
canvas.width = window.innerWidth;
}
resize_canvas();
window.addEventListener("resize", resize_canvas);
//Characters
const c = new Character(window);
//Modify State & Draw
let ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
if (!ctx) return null;
function delete_last_state() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
function state() {
c.loop(canvas, ctx);
}
function loop() {
delete_last_state();
state();
window.requestAnimationFrame(loop);
}
window.requestAnimationFrame(loop);
}
¡Y eso es todo!
En resumen, esto es solo una animación simple; sin embargo, con la forma en que lo hicimos, es muy fácil agregar algunos event listeners y actualizar la lógica de movimiento, y podríamos tener un personaje real en pantalla.
Conclusión
Con menos de 170 líneas de código, creamos esos salvapantallas de DVD que solía amar a principios de los 2000. Aquí tienes un resumen de lo que aprendimos:
Cómo representar objetos de juego con clases
Cómo usar enums para manejar direcciones
Cómo encapsular comportamiento dentro de clases reutilizables (Power)
Cómo animar en el navegador con requestAnimationFrame
Este tipo de pensamiento forma la base del desarrollo de videojuegos y la programación de gráficos interactivos.