Aprende a crear juegos en HTML5 Canvas

domingo, 8 de febrero de 2015

Volteando imágenes

Ahora que estamos aprendiendo a hacer juegos de plataformas, hay varias técnicas importantes que es bueno conocer a la hora de agregarles imágenes. Para este proyecto ya he preparado la siguiente hoja de sprites:


En esta ocasión, he decidido hacer el juego de un estilo más tradicional, respetando usar gráficos en potencias de dos (16x16 los bloques, y 16x32 el personaje), y ajustando el tamaño del lienzo a un formato 3:2 de 240x160 pixeles. Para empezar, agregaremos las siguiente línea en el contrsuctor del rectángulo:
        this.scale = {x: 1, y: 1};
Nota que estamos agregando este objeto en el constructor y no en el prototipo, pues de hacerlo directamente en el prototipo, todos los objetos de tipo rectángulo estarían compartiendo el mismo objeto "scale" dentro de ellos, de igual forma que pasa con las funciones.

Dibujar una imagen con un efecto de "flip" (volteado) realmente no es muy complejo, lo único que se necesita es escalar el contexto a un valor negativo, y al momento de dibujar la imagen, esta se dibujará volteada, horizontal si la escala negativa está en X, y vertical si la escala negativa está en Y. Por ejemplo, si tenemos una imagen que voltea a la derecha, y queremos que ahora voltee a la izquierda, dibujada a su tamaño original, debemos aplicar la escala (-1, 1).

Sólo hay que recalcar dos puntos a la hora de hacer un volteado: si queremos una imagen el doble de grande, la escala negativa debe ser igualmente el doble de su tamaño original (por ejemplo, (-2, 2)), y al igual que al rotar una imagen, esta debe ser siempre dibujada desde el centro de su objeto, de lo contrario quedaría desfasada a su punto de origen. Afortunadamente ya vimos con anterioridad como dibujar imágenes rotadas desde una hoja de sprites, por lo que esta vez solo modificaremos ligeramente la misma función para que dibuje imágenes escaladas, y así poder aplicarle un volteado:
        drawImageArea: function (ctx, cam, img, sx, sy, sw, sh) {
            if (ctx !== undefined) {
                if (img.width) {
                    ctx.save();
                    if (cam !== undefined) {
                        ctx.translate(this.x - cam.x, this.y - cam.y);
                    } else {
                        ctx.translate(this.x, this.y);
                    }
                    ctx.scale(this.scale.x, this.scale.y);
                    //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 {
                    if (cam !== undefined) {
                        ctx.strokeRect(this.left - cam.x, this.top - cam.y, this.width, this.height);
                    } else {
                        ctx.strokeRect(this.left, this.top, this.width, this.height);
                    }
                }
            }
        }
Ahora que nuestra función de dibujado está preparada, es tiempo de aplicar el volteado a nuestro personaje. Para ello, cuando detectemos que la tecla izquierda es presionada, asignaremos a la escala del personaje un valor de -1 antes de asignar su velocidad correspondiente:
            // Set vectors
            if (pressing[KEY_LEFT]) {
                player.scale.x = -1;
                if (player.vx > -10) {
                    player.vx -= 1;
                }
            }
De igual forma se debe hacer con la tecla derecha, asignando un valor de 1.

Finalmente, dibujaremos nuestro personaje. Es popular en los juegos de plataformas, que los personajes tengan muchos estados de animación, dependiendo la acción que estén realizando. A continuación muestro una forma sencilla de como evaluar los diferentes estados básicos de nuestro personaje:

Primero que nada, saber si está en el aire es lo que lleva mayor prioridad, y en tal caso, dibujar una imagen de salto. Dado que en la hoja de sprites no hemos agregado una imagen específica para el salto, tomaré la segunda imagen de la animación de caminado, que se acopla muy bien por ahora para este estado. En caso contrario, sabiendo que está en el suelo, verificaremos primero si la velocidad del personaje es cero, en cuyo caso significa que está quieto, y por tanto dibujaremos la primer imagen. Si resulta ser el caso opuesto, significa que está en movimiento, por lo que dibujaremos una animación entre la primera y segunda imagen, auxiliados por una variable "elapsed" que hemos aprendido a usar ya en lecciones pasadas. El código para esta evaluación quedaría entonces de esta forma:
        // Draw player
        ctx.strokeStyle = '#0f0';
        if (!onGround) {
            player.drawImageArea(ctx, cam, spritesheet, 16, 16, 16, 32);
        } else if (player.vx === 0) {
            player.drawImageArea(ctx, cam, spritesheet, 0, 16, 16, 32);
        } else {
            player.drawImageArea(ctx, cam, spritesheet, (~~(elapsed * 10) % 2) * 16, 16, 16, 32);
        }
Es común en los juegos de plataformas tener dos estados para cuando el personaje está en el aire: salto y caída. Dicho estado es determinado por la velocidad en el eje Y, si este es menor a 0, podemos tener por seguro que apenas se está impulsando hacia arriba, de lo contrario, significa que se está cayendo. Por ahora, no evaluaremos estos casos en el presente ejemplo, pero es información que podría ser de ayuda para ti en juegos futuros de plataformas que desarrolles.

Ahora que nuestro personaje está listo, es tiempo de actualizar nuestro mapa para arreglarlo de mejor forma para un juego de plataformas. Para empezar, removamos todo el código relativo a la lava, que ya no ocuparemos en este juego. Anteriormente, cada número en nuestro arreglo de mapa representaba un objeto diferente con un comportamiento diferente. Ahora queremos tener varios tipos de bloques pero todos con el mismo comportamiento, para que las paredes tengan diferente aspecto dependiendo de dónde los localicemos. Para ello, agregaremos una variable "type" en el prototipo del rectángulo:
        type: 0,
Posteriormente, modificaremos ligeramente la función "setMap", en la parte donde agregamos las partes del mapa a sus respectivos arreglos, agregando todo lo que sea mayor a cero a las paredes, asignando antes el tipo de material del que se trata:
                if (map[row][col] > 0) {
                    rect = new Rectangle2D(col * blockSize, row * blockSize, blockSize, blockSize, true);
                    rect.type = map[row][col];
                    wall.push(rect);
                }
De esta forma, observando la hoja de sprites que tenemos actualmente, el valor 1 se trata del suelo nevado, el valor 2 de los bloques de nieve, y el valor 3 de los bloques de hielo. Para dibujarlos con su respectivo tipo, iteramos sobre cada uno de ellos, usando el tipo de bloque con la siguiente fórmula:
        // Draw walls
        ctx.strokeStyle = '#999';
        for (i = 0, l = wall.length; i < l; i += 1) {
            wall[i].drawImageArea(ctx, cam, spritesheet, (wall[i].type - 1) * 16, 0, 16, 16);
        }
Solo resta diseñar el nivel con los nuevos tipos de bloque. Para dar sentido a estos, he asignado el suelo solo a los bloques en la parte inferior, los bloques nieve a los lugares que están soportados por el suelo, y los bloques de hielo a las plataformas flotantes. Puedes ver el diseño final a continuación en el ejemplo final. Con esto, has aprendido las bases de como usar imágenes en un juego de plataformas.

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,
        pressing = [],
        pause = false,
        gameover = true,
        onGround = false,
        worldWidth = 0,
        worldHeight = 0,
        elapsed = 0,
        cam = null,
        player = null,
        wall = [],
        spritesheet = new Image(),
        map0 = [
            [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2],
            [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2],
            [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2],
            [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2],
            [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 0, 0, 2],
            [2, 0, 0, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 0, 0, 2],
            [2, 0, 0, 0, 0, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2],
            [2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2],
            [2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2],
            [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
        ];

    function Camera() {
        this.x = 0;
        this.y = 0;
    }

    Camera.prototype = {
        constructor: Camera,
        
        focus: function (x, y) {
            this.x = x - canvas.width / 2;
            this.y = y - canvas.height / 2;

            if (this.x < 0) {
                this.x = 0;
            } else if (this.x > worldWidth - canvas.width) {
                this.x = worldWidth - canvas.width;
            }
            if (this.y < 0) {
                this.y = 0;
            } else if (this.y > worldHeight - canvas.height) {
                this.y = worldHeight - canvas.height;
            }
        }
    };
    
    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;
        }
        this.scale = {x: 1, y: 1};
    }
    
    Rectangle2D.prototype = {
        constructor: Rectangle2D,
        left: 0,
        top: 0,
        width: 0,
        height: 0,
        vx: 0,
        vy: 0,
        type: 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;
        },
        
        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) {
                if (cam !== undefined) {
                    ctx.fillRect(this.left - cam.x, this.top - cam.y, this.width, this.height);
                } else {
                    ctx.fillRect(this.left, this.top, this.width, this.height);
                }
            }
        },
        
        drawImageArea: function (ctx, cam, img, sx, sy, sw, sh) {
            if (ctx !== undefined) {
                if (img.width) {
                    ctx.save();
                    if (cam !== undefined) {
                        ctx.translate(this.x - cam.x, this.y - cam.y);
                    } else {
                        ctx.translate(this.x, this.y);
                    }
                    ctx.scale(this.scale.x, this.scale.y);
                    //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 {
                    if (cam !== undefined) {
                        ctx.strokeRect(this.left - cam.x, this.top - cam.y, this.width, this.height);
                    } else {
                        ctx.strokeRect(this.left, this.top, this.width, this.height);
                    }
                }
            }
        }
    };

    document.addEventListener('keydown', function (evt) {
        if (!pressing[evt.which]) {
            lastPress = evt.which;
        }
        pressing[evt.which] = true;
    }, false);

    document.addEventListener('keyup', function (evt) {
        pressing[evt.which] = false;
    }, false);

    function setMap(map, blockSize) {
        var col = 0,
            row = 0,
            columns = 0,
            rows = 0,
            rect = null;
        wall.length = 0;
        for (row = 0, rows = map.length; row < rows; row += 1) {
            for (col = 0, columns = map[row].length; col < columns; col += 1) {
                if (map[row][col] > 0) {
                    rect = new Rectangle2D(col * blockSize, row * blockSize, blockSize, blockSize, true);
                    rect.type = map[row][col];
                    wall.push(rect);
                }
            }
        }
        worldWidth = columns * blockSize;
        worldHeight = rows * blockSize;
    }

    function reset() {
        player.left = 48;
        player.top = 16;
        player.vx = 0;
        player.vy = 0;
        gameover = false;
    }

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

        // Draw player
        ctx.strokeStyle = '#0f0';
        if (!onGround) {
            player.drawImageArea(ctx, cam, spritesheet, 16, 16, 16, 32);
        } else if (player.vx === 0) {
            player.drawImageArea(ctx, cam, spritesheet, 0, 16, 16, 32);
        } else {
            player.drawImageArea(ctx, cam, spritesheet, (~~(elapsed * 10) % 2) * 16, 16, 16, 32);
        }

        // Draw walls
        ctx.strokeStyle = '#999';
        for (i = 0, l = wall.length; i < l; i += 1) {
            wall[i].drawImageArea(ctx, cam, spritesheet, (wall[i].type - 1) * 16, 0, 16, 16);
        }

        // Debug last key pressed
        ctx.fillStyle = '#fff';
        //ctx.fillText('Last Press: ' + lastPress, 0, 20);
        
        // Draw pause
        if (pause) {
            ctx.textAlign = 'center';
            if (gameover) {
                ctx.fillText('GAMEOVER', canvas.width / 2, canvas.height / 2);
            } else {
                ctx.fillText('PAUSE', canvas.width / 2, canvas.height / 2);
            }
            ctx.textAlign = 'left';
        }
    }

    function act(deltaTime) {
        var i = 0,
            l = 0;
        
        if (!pause) {
            // GameOver Reset
            if (gameover) {
                reset();
            }

            // Set vectors
            if (pressing[KEY_RIGHT]) {
                player.scale.x = 1;
                if (player.vx < 10) {
                    player.vx += 1;
                }
            } else if (player.vx > 0) {
                player.vx -= 1;
            }
            if (pressing[KEY_LEFT]) {
                player.scale.x = -1;
                if (player.vx > -10) {
                    player.vx -= 1;
                }
            } else if (player.vx < 0) {
                player.vx += 1;
            }

            // Gravity
            player.vy += 1;
            if (player.vy > 10) {
                player.vy = 10;
            }

            // Jump
            if (onGround && lastPress === KEY_UP) {
                player.vy = -10;
            }

            // Move player in x
            player.x += player.vx;
            for (i = 0, l = wall.length; i < l; i += 1) {
                if (player.intersects(wall[i])) {
                    if (player.vx > 0) {
                        player.right = wall[i].left;
                    } else {
                        player.left = wall[i].right;
                    }
                    player.vx = 0;
                }
            }
            
            // Move player in y
            onGround = false;
            player.y += player.vy;
            for (i = 0, l = wall.length; i < l; i += 1) {
                if (player.intersects(wall[i])) {
                    if (player.vy > 0) {
                        player.bottom = wall[i].top;
                        onGround = true;
                    } else {
                        player.top = wall[i].bottom;
                    }
                    player.vy = 0;
                }
            }

            // Out Screen
            if (player.x > worldWidth) {
                player.x = 0;
            }
            if (player.x < 0) {
                player.x = worldWidth;
            }

            // Bellow world
            if (player.y > worldHeight) {
                gameover = true;
                pause = true;
            }

            // Focus player
            cam.focus(player.x, player.y);

            // Elapsed time
            elapsed += deltaTime;
            if (elapsed > 3600) {
                elapsed -= 3600;
            }
        }
        
        // Pause/Unpause
        if (lastPress === KEY_ENTER) {
            pause = !pause;
        }
    }

    function repaint() {
        window.requestAnimationFrame(repaint);
        paint(ctx);
    }

    function run() {
        setTimeout(run, 50);
        act(0.05);

        lastPress = null;
    }

    function init() {
        // Get canvas and context
        canvas = document.getElementById('canvas');
        ctx = canvas.getContext('2d');
        canvas.width = 240;
        canvas.height = 160;
        worldWidth = canvas.width;
        worldHeight = canvas.height;
        
        // Load assets
        spritesheet.src = 'assets/platformer-sprites.png';
        
        // Create camera and player
        cam = new Camera();
        player = new Rectangle2D(48, 16, 16, 32, true);

        // Set initial map
        setMap(map0, 16);
        
        // Start game
        run();
        repaint();
    }

    window.addEventListener('load', init, false);
}(window));
Regresar al índice

11 comentarios:

  1. Gracias por continuar con estos excelentes tutoriales, pero tengo una pregunta en cuanto a las imagenes, yo estoy realizando un juego en el cual estoy usando las imagenes no como tiles es decir uso una imagen como objeto exceptuando en las imagenes que tengan animacion las cuales los tiles serian cada una de las imagenes de la secuencia, entonces las imagenes algunas son muy grandes como en el fondo que pueden llegar a ser de 7200px por casi 1000px, entonces en dispositivos que tengan buena memoria parece no haber mayor problema pero en celulares aunque sean buenos el fondo no aparece debido a su tamaño, esto parece solucionarse al no poner imagenes que superen los 1024px X 1024px, entonces hay esta mi pregunta si seria bueno usar imagenes de menos de 1024 o tendria que usar una matriz de tiles como suele usarse en estos juegos???, tambien mas haya de los fondos que son muy grandes, los obstaculos, escaleras y demas si podria usarlos como imagenes enteras o quizas no es la mejor practica?????, lo digo porque es mucho mas sencillo manejar las colisiones con una sola imagen que un objeto que este compuesto de varias, como una escalera en la cual solo tengo que mirar si colisiona con la imagen y no con 3 o mas partes para escalar.

    ResponderBorrar
    Respuestas
    1. Independiente a si usas o no imágenes en mosaico, es muy recomendable (y a veces necesario como ya habrás visto) usar imágenes de máximo 1024x1024.

      Sobre si usar imágenes de mosaico o no, bien sabrás que tiene sus ventajas y desventajas. Cierto es que resulta más sencillo tener un objeto por cada tipo de colisión, pero las imágenes de mosaico permiten mayor flexibilidad cuando son dominadas. Si quieres escaleras, tendrás que hacer diferentes imágenes para cada altura de escalera, mientras que con imágenes de mosaico, solo delimitas su altura y tu código llenará el espacio necesario. También puedes cambiar su aspecto de forma fácil al cambiar la imagen de mosaico base en cada nivel, teniendo así en distintas locaciones escaleras de madera, de metal, lianas y demás, con el mismo comportamiento y sin tener que mover el código.

      Finalmente, es bueno recordar también que las imágenes en mosaico ocupan menos espacio en disco, memoria, procesamiento y transferencia de datos cuando estas son web.

      En resumen, es recomendado usar imágenes de mosaico cuando sea posible, aunque no obligatorio y también no aplica en todos los juegos. Al final, esta decisión depende de ti.

      ¡Mucha suerte! ¡Felices códigos!

      Borrar
    2. vale aprecio mucho tus comentarios, te tengo otra pregunta o mas bien que me des un consejo, en cuanto a las colisiones y a la inteligencia artificial, que algoritmos conocidos me recomiendas mirar, o quizas algun articulo o que debo investigar para plantear bien estos dos temas en mi juego.

      Borrar
    3. Todo depende de que tipo deseas usar para tu juego, y la complejidad que tengas planteado para ellos.

      Borrar
  2. Gracias por tus aportes, ahora te tengo una pregunta en cuanto a colisiones en el framework que uso me pide que le pase el spritesheet y que le defina el tamaño y la cantidad de columnas y filas y asi el lo divide y yo llamo para alguna animacion el tile correspondiente, pero entonces cada sprite tiene el mismo tamaño lo cual es un problema para colisiones ya que por ejemplo la animacion de caminar cada imagen tendra el mismo tamaño que la de agacharse en donde esta ultima seria gravemente afectada en las colisiones ya que por ejemplo un projectil que no deberia herirlo al estar agachado lo heriria ya que la colision se produciria, entonces no se como manejar este asunto bien ya que si pusiera cada animacion por separado o aun peor cada imagen por separado, animar seria un gasto increible ya que tendria que volver a crear continuamente la variable a la que le paso la imagen, ya que no puedo cambiar la imagen de la variable(personaje) dinamicamente, y creando nuevamente la variable una y otra vez tomaria mucha memoria, entonces no se que me recomendarias hacer o si quizas hay alguna tecnica que desconozco que me ayudaria en este tema, gracias.

    ResponderBorrar
    Respuestas
    1. ¿Qué framework estás usando? Todo depende de él. Si te permite redefinir la colisión, es lo óptimo. Si te permitiera redefinir la imagen (dices que no) sería bueno. Si no, problemente lo mejor que puedes hacer con ese framework, es lo que dices de cambiar el objeto dependiendo su área de colisión.

      Es una de las cosas que más amo de hacer todo desde 0, que si bien es mucho más lento que desarrollar usando un framework, los limites de lo que puedes hacer, sólo te los impones tú mismo ;)

      Por ahora, busquemos la mejor solución para tu caso específico con este framework.

      Borrar
    2. ps mira el framework que uso es andEngine, y si hasta donde se y he investigado no es posible cambiar dinamicamente la imagen de un objeto sprite o en este caso animatedSprite.

      Borrar
    3. Temo que no conozco suficiente de AndEngine para saber cómo solicionar el problema que planteas. Posiblemente la solución que propusiste antes sea la mejor.

      Borrar
  3. Hola, sabes como se podria hacer para que el personaje se volviere invencible temporalmente al ser golpeado con un limite de tiempo(muy al estilo marioBros)? no te pregundo como poner enemigos o ser dañado por ellos, ya que en los tutoriales enterioes explicas como XD, pero sabes como se podria?

    ResponderBorrar
    Respuestas
    1. Lo que requieres es un temporizador, como vimos en el tema Dispara al Objetivo. cuando obtengas tu el objeto que te hace invencible, asignas el temporizador a la cantidad de segundos que deseas dure este estado. Si el temporizador es mayor a 0, le restas deltaTime y actúas según su estado invensible, y en caso contrario, actúas como es su caso normal, posiblemente haciendo el respectivo daño a tu jugador.

      ¡Suerte!

      Borrar
  4. Como siempre muy buenos post gracias, por sierto tengo un par de dudas, en juegos como metal slug que es uno de los mayores exponentes en cuanto a graficos 2d se refiere, hay un monton de cosas que suceden al mismo tiempo, y no me refiero solo a personajes sino el ambiente, los lugares por ejemplo explotan y quedan en ruinas, las naves por ejemplo que son enormes tambien tienen distintas faces donde se ven que van siendo dañadas, hay transiciones como de power point entre escenas(es decir que termina la escena y pasa a otra y entre estas dos se ve por ejemplo un efecto cortina), tambien por ejemplo en cuanto a rayos por ejemplo hay disparos laser por asi decirlo que son gigantescos porque vienen de una maquina que es enorme, entonces no se si tienes idea de como se realizan muchas de estas cosas que son graficas, si conoces alguna info donde pueda investigar esto o algo, ya que la idea es llevar los juegos al limite grafico y ps hay muchas cosas que no se me ocurre como es que se hacen.

    ResponderBorrar