¿Ejecutando múltiples bucles de requestAnimation para disparar múltiples bolas?

Oct 15 2020

Estoy tratando de hacer que la bola de abajo siga apareciendo y disparando a través del eje y en un intervalo establecido y siempre desde donde está la posición x de la paleta (mouse), necesito que haya un retraso entre cada disparo de la bola. Estoy tratando de hacer invasores del espacio pero con la pelota disparando constantemente a un intervalo establecido.

¿Necesito crear múltiples bucles requestAnimationFrame para cada bola? ¿Alguien puede ayudar con un ejemplo muy básico de cómo se debe hacer esto por favor o enlazar un buen artículo? Estoy atrapado en la creación de una matriz para cada bola y no estoy seguro de cómo diseñar el bucle para lograr este efecto. Todos los ejemplos que puedo encontrar son demasiado complejos.


<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8" />
  <style>
    * {
      padding: 0;
      margin: 0;
    }

    canvas {
      background: #eee;
      display: block;
      margin: 0 auto;
      width: 30%;
    }
  </style>
</head>

<body>
  <canvas id="myCanvas" height="400"></canvas>

  <script>

    var canvas = document.getElementById("myCanvas");
    var ctx = canvas.getContext("2d");
    //start the requestAnimationFrame loop
    var myRequestAnimation;
    var myRequestAnimationBall;
    var requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame ||
      window.webkitRequestAnimationFrame || window.msRequestAnimationFrame;
    var cancelAnimationFrame = window.cancelAnimationFrame || window.mozCancelAnimationFrame;
    drawLoop();
    setInterval(drawBallLoop, 400);

    var x = canvas.width / 2;
    var y = canvas.height - 30;
    var defaultSpeedX = 0;
    var defaultSpeedY = 4;
    var dx = defaultSpeedX;
    var dy = -defaultSpeedY;
    var ballRadius = 10;

    var paddleX = (canvas.width - paddleWidth) / 2;

    var paddleHeight = 10;
    var paddleWidth = 70;

    //control stuff
    var rightPressed = false;
    var leftPressed = false;

    var brickRowCount = 1;
    var brickColumnCount = 1;
    var brickWidth = 40;
    var brickHeight = 20;
    var brickPadding = 10;
    var brickOffsetTop = 30;
    var brickOffsetLeft = 30;

    var score = 0;
    var lives = 3;



  

    //paddle
    function drawPaddle() {
      ctx.beginPath();
      ctx.rect(paddleX, canvas.height - paddleHeight, paddleWidth, paddleHeight);
      ctx.fillStyle = "#0095DD";
      ctx.fill();
      ctx.closePath();
    }

    //bricks
    function drawBricks() {
      for (var c = 0; c < brickColumnCount; c++) {
        for (var r = 0; r < brickRowCount; r++) {
          if (bricks[c][r].status == 1) {
            var brickX = (c * (brickWidth + brickPadding)) + brickOffsetLeft;
            var brickY = (r * (brickHeight + brickPadding)) + brickOffsetTop;
            bricks[c][r].x = brickX;
            bricks[c][r].y = brickY;
            ctx.beginPath();
            ctx.rect(brickX, brickY, brickWidth, brickHeight);
            ctx.fillStyle = "#0095DD";
            ctx.fill();
            ctx.closePath();
          }
        }
      }
    }

    //collision detection
    function collisionDetection() {
      for (var c = 0; c < brickColumnCount; c++) {
        for (var r = 0; r < brickRowCount; r++) {
          var b = bricks[c][r];
          if (b.status == 1) {
            if (x > b.x && x < b.x + brickWidth && y > b.y && y < b.y + brickHeight) {
              dy = -dy;
              b.status = 0;
              score++;
              console.log(score);
              if (score == brickRowCount * brickColumnCount) {
                console.log("YOU WIN, CONGRATS!");
                window.cancelAnimationFrame(myRequestAnimation);
              }
            }
          }
        }
      }
    }

    //default bricks
    var bricks = [];
    for (var c = 0; c < brickColumnCount; c++) {
      bricks[c] = [];
      for (var r = 0; r < brickRowCount; r++) {
        bricks[c][r] = { x: 0, y: 0, status: 1 };
      }
    }

    //lives
    function drawLives() {
      ctx.font = "16px Arial";
      ctx.fillStyle = "#0095DD";
      ctx.fillText("Lives: " + lives, canvas.width - 65, 20);
    }


    // ball1
    var ball1 = {
      x,
      y,
      directionX: 0,
      directionY: -5
    }

    // ball1
    var ball2 = {
      x,
      y,
      directionX: 0,
      directionY: -2
    }

    // put each ball in a balls[] array
    var balls = [ball1, ball2];

    
    function drawBall() {
      // clearCanvas();
      for (var i = 0; i < balls.length; i++) {
        var ball = balls[i]

        ctx.beginPath();
        ctx.arc(ball.x, ball.y, ballRadius, 0, Math.PI * 2);
        ctx.fillStyle = "#0095DD";
        ctx.fill();
        ctx.closePath();
      }
    }

    ///////DRAW BALL LOOP////////
    function drawBallLoop() {
      myRequestAnimationBall = requestAnimationFrame(drawBallLoop);

      // clear frame
      //ctx.clearRect(0, 0, canvas.width, canvas.height);

      //draw ball
      drawBall();

      //move balls
      for (var i = 0; i < balls.length; i++) {
        balls[i].y += balls[i].directionY;
      }
    }

    //Clear Canvas
    function clearCanvas() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
    }





    ///////DRAW MAIN LOOP////////
    function drawLoop() {
      myRequestAnimation = requestAnimationFrame(drawLoop);

      // clear frame
      ctx.clearRect(0, 0, canvas.width, canvas.height);

      //draw ball
      drawPaddle();
      drawBricks();
      collisionDetection();
      drawLives();


      //bounce off walls
      if (x + dx > canvas.width - ballRadius || x + dx < ballRadius) {
        dx = -dx;
      }

      if (rightPressed) {
        paddleX += 7;
        if (paddleX + paddleWidth > canvas.width) {
          paddleX = canvas.width - paddleWidth;
        }
      }

      else if (leftPressed) {
        paddleX -= 7;
        if (paddleX < 0) {
          paddleX = 0;
        }
      }
    }


    //keyboard left/right logic
    document.addEventListener("keydown", keyDownHandler, false);
    document.addEventListener("keyup", keyUpHandler, false);
    function keyDownHandler(e) {
      if (e.key == "Right" || e.key == "ArrowRight") {
        rightPressed = true;
      }
      else if (e.key == "Left" || e.key == "ArrowLeft") {
        leftPressed = true;
      }
    }
    function keyUpHandler(e) {
      if (e.key == "Right" || e.key == "ArrowRight") {
        rightPressed = false;
      }
      else if (e.key == "Left" || e.key == "ArrowLeft") {
        leftPressed = false;
      }
    }

    //relative mouse pos
    function getMousePos(canvas, evt) {
      var rect = canvas.getBoundingClientRect(), // abs. size of element
        scaleX = canvas.width / rect.width,    // relationship bitmap vs. element for X
        scaleY = canvas.height / rect.height;  // relationship bitmap vs. element for Y

      return {
        x: (evt.clientX - rect.left) * scaleX,   // scale mouse coordinates after they have
        y: (evt.clientY - rect.top) * scaleY     // been adjusted to be relative to element
      }
    }

    //mouse movemment
    document.addEventListener("mousemove", mouseMoveHandler, false);

    function mouseMoveHandler(e) {
      var mouseX = getMousePos(canvas, e).x;
      //e.clientX = the horizontal mouse position in the viewport
      //canvas.offsetLeft = the distance between the left edge of the canvas and left edge of the viewport
      var relativeX = mouseX;
      // console.log('mouse= ',relativeX, canvas.offsetLeft)
      // console.log('paddle= ', paddleX);
      // console.log(getMousePos(canvas, e).x);

      if (relativeX - (paddleWidth / 2) > 0 && relativeX < canvas.width - (paddleWidth / 2)) {
        paddleX = relativeX - (paddleWidth / 2);
      }
    }


  </script>

</body>

</html>

Respuestas

2 Sheraff Oct 18 2020 at 15:32

Principios básicos

Aquí tienes una forma de hacerlo:

  1. necesita un Gameobjeto que maneje la lógica de actualización, almacene todas las entidades actuales, maneje el ciclo del juego ... OMI, aquí es donde debe realizar un seguimiento de cuándo fue el último Balldisparado y si disparar uno nuevo.

    En esta demostración, este objeto también maneja el tiempo actual, el tiempo delta y la solicitud de cuadros de animación, pero algunos podrían argumentar que esta lógica podría externalizarse y simplemente llamar a algún tipo de Game.update(deltaTime)en cada cuadro.


  1. necesitas diferentes objetos para todas las entidades en tu juego. Creé una Entityclase porque quiero asegurarme de que todas las entidades del juego tengan el mínimo requerido para funcionar (es decir, actualizar, dibujar, x, y ...).

    Hay una Ballclase que extends Entityy se encarga de conocer sus propios parámetros (velocidad, tamaño, ...), cómo actualizarse y dibujarse, ...

    Hay una Paddleclase que dejé al descubierto para que la completes.


La conclusión es que todo es cuestión de separación de preocupaciones. ¿Quién debería saber qué de quién? Y luego pasar las variables.


En cuanto a tu otra pregunta:

¿Necesito crear múltiples bucles requestAnimationFrame para cada bola?

Es definitivamente posible, pero yo diría que tener un lugar centralizado que los mangos lastUpdate, deltaTime, lastBallCreatedhace las cosas mucho más simple. Y en la práctica, los desarrolladores tienden a intentar tener un único bucle de cuadro de animación para esto.

class Entity {
    constructor(x, y) {
        this.x = x
        this.y = y
    }

    update() { console.warn(`${this.constructor.name} needs an update() function`) } draw() { console.warn(`${this.constructor.name} needs a draw() function`) }
    isDead() { console.warn(`${this.constructor.name} needs an isDead() function`) }
}

class Ball extends Entity {
    constructor(x, y) {
        super(x, y)
        this.speed = 100 // px per second
        this.size = 10 // radius in px
    }

    update(deltaTime) {
        this.y -= this.speed * deltaTime / 1000 // deltaTime is ms so we divide by 1000
    }

    /** @param {CanvasRenderingContext2D} context */
    draw(context) {
        context.beginPath()
        context.arc(this.x, this.y, this.size, 0, 2 * Math.PI)
        context.fill()
    }

    isDead() {
        return this.y < 0 - this.size
    }
}

class Paddle extends Entity {
    constructor() {
        super(0, 0)
    }

    update() { /**/ }
    draw() { /**/ }
    isDead() { return false }
}

class Game {
    /** @param {HTMLCanvasElement} canvas */
    constructor(canvas) {
        this.entities = [] // contains all game entities (Balls, Paddles, ...)
        this.context = canvas.getContext('2d')
        this.newBallInterval = 1000 // ms between each ball
        this.lastBallCreated = 0 // timestamp of last time a ball was launched
    }

    start() {
        this.lastUpdate = performance.now()
        const paddle = new Paddle()
        this.entities.push(paddle)
        this.loop()
    }

    update() {
        // calculate time elapsed
        const newTime = performance.now()
        const deltaTime = newTime - this.lastUpdate

        // update every entity
        this.entities.forEach(entity => entity.update(deltaTime))

        // other update logic (here, create new entities)
        if(this.lastBallCreated + this.newBallInterval < newTime) {
            const ball = new Ball(100, 300) // this is quick and dirty, you should put some more thought into `x` and `y` here
            this.entities.push(ball)
            this.lastBallCreated = newTime
        }

        // remember current time for next update
        this.lastUpdate = newTime
    }

    draw() {
        this.entities.forEach(entity => entity.draw(this.context))
    }

    cleanup() {
        // to prevent memory leak, don't forget to cleanup dead entities
        this.entities.forEach(entity => {
            if(entity.isDead()) {
                const index = this.entities.indexOf(entity)
                this.entities.splice(index, 1)
            }
        })
    }

    loop() {
        requestAnimationFrame(() => {
            this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height)
            this.update()
            this.draw()
            this.cleanup()
            this.loop()
        })
    }
}

const canvas = document.querySelector('canvas')
const game = new Game(canvas)
game.start()
<canvas height="300" width="300"></canvas>


Gestionar las entradas de los jugadores

Ahora digamos que desea agregar entradas de teclado a su juego. En ese caso, en realidad crearía una clase separada, porque dependiendo de cuántos "botones" quieras admitir, puede volverse muy complicado muy rápido.

Entonces, primero, dibujemos una paleta básica para que podamos ver qué está sucediendo:

class Paddle extends Entity {
    constructor() {
        // we just add a default initial x,y and height,width
        super(150, 20)
        this.width = 50
        this.height = 10
    }

    update() { /**/ }

    /** @param {CanvasRenderingContext2D} context */
    draw(context) { 
        // we just draw a simple rectangle centered on x,y
        context.beginPath()
        context.rect(this.x - this.width / 2, this.y - this.height / 2, this.width, this.height)
        context.fill()
    }

    isDead() { return false }
}

Y ahora agregamos una InputsManagerclase básica que puedes hacer tan complicada como quieras. Solo por dos teclas, el manejo keydowny keyupel hecho de que se pueden presionar dos teclas a la vez, ya son algunas líneas de código, por lo que es bueno mantener las cosas separadas para no abarrotar nuestro Gameobjeto.

class InputsManager {
    constructor() {
        this.direction = 0 // this is the value we actually need in out Game object
        window.addEventListener('keydown', this.onKeydown.bind(this))
        window.addEventListener('keyup', this.onKeyup.bind(this))
    }

    onKeydown(event) {
        switch (event.key) {
            case 'ArrowLeft':
                this.direction = -1
                break
            case 'ArrowRight':
                this.direction = 1
                break
        }
    }

    onKeyup(event) {
        switch (event.key) {
            case 'ArrowLeft':
                if(this.direction === -1) // make sure the direction was set by this key before resetting it
                    this.direction = 0
                break
            case 'ArrowRight':
                this.direction = 1
                if(this.direction === 1) // make sure the direction was set by this key before resetting it
                    this.direction = 0
                break
        }
    }
}

Ahora, podemos actualizar nuestra Gameclase para hacer uso de esta nuevaInputsManager

class Game {

    // ...

    start() {
        // ...
        this.inputsManager = new InputsManager()
        this.loop()
    }

    update() {
        // update every entity
        const frameData = {
            deltaTime,
            inputs: this.inputsManager,
        } // we now pass more data to the update method so that entities that need to can also read from our InputsManager
        this.entities.forEach(entity => entity.update(frameData))
    }

    // ...

}

Y después de actualizar el código para que los updatemétodos de nuestras entidades usen realmente el nuevo InputsManager, aquí está el resultado:

class Entity {
    constructor(x, y) {
        this.x = x
        this.y = y
    }

    update() { console.warn(`${this.constructor.name} needs an update() function`) }
    draw() { console.warn(`${this.constructor.name} needs a draw() function`) } isDead() { console.warn(`${this.constructor.name} needs an isDead() function`) }
}

class Ball extends Entity {
    constructor(x, y) {
        super(x, y)
        this.speed = 300 // px per second
        this.radius = 10 // radius in px
    }

    update({deltaTime}) {
    // Ball still only needs deltaTime to calculate its update
        this.y -= this.speed * deltaTime / 1000 // deltaTime is ms so we divide by 1000
    }

    /** @param {CanvasRenderingContext2D} context */
    draw(context) {
        context.beginPath()
        context.arc(this.x, this.y, this.radius, 0, 2 * Math.PI)
        context.fill()
    }

    isDead() {
        return this.y < 0 - this.radius
    }
}

class Paddle extends Entity {
    constructor() {
        super(150, 50)
        this.speed = 200
        this.width = 50
        this.height = 10
    }

    update({deltaTime, inputs}) {
    // Paddle needs to read both deltaTime and inputs
        this.x += this.speed * deltaTime / 1000 * inputs.direction
    }

    /** @param {CanvasRenderingContext2D} context */
    draw(context) { 
        context.beginPath()
        context.rect(this.x - this.width / 2, this.y - this.height / 2, this.width, this.height)
        context.fill()
    }

    isDead() { return false }
}

class InputsManager {
    constructor() {
        this.direction = 0
        window.addEventListener('keydown', this.onKeydown.bind(this))
        window.addEventListener('keyup', this.onKeyup.bind(this))
    }

    onKeydown(event) {
        switch (event.key) {
            case 'ArrowLeft':
                this.direction = -1
                break
            case 'ArrowRight':
                this.direction = 1
                break
        }
    }

    onKeyup(event) {
        switch (event.key) {
            case 'ArrowLeft':
                if(this.direction === -1) 
                    this.direction = 0
                break
            case 'ArrowRight':
                this.direction = 1
                if(this.direction === 1)
                    this.direction = 0
                break
        }
    }
}

class Game {
    /** @param {HTMLCanvasElement} canvas */
    constructor(canvas) {
        this.entities = [] // contains all game entities (Balls, Paddles, ...)
        this.context = canvas.getContext('2d')
        this.newBallInterval = 500 // ms between each ball
        this.lastBallCreated = -Infinity // timestamp of last time a ball was launched
    }

    start() {
        this.lastUpdate = performance.now()
    // we store the new Paddle in this.player so we can read from it later
        this.player = new Paddle()
    // but we still add it to the entities list so it gets updated like every other Entity
        this.entities.push(this.player)
        this.inputsManager = new InputsManager()
        this.loop()
    }

    update() {
        // calculate time elapsed
        const newTime = performance.now()
        const deltaTime = newTime - this.lastUpdate

        // update every entity
        const frameData = {
            deltaTime,
            inputs: this.inputsManager,
        }
        this.entities.forEach(entity => entity.update(frameData))

        // other update logic (here, create new entities)
        if(this.lastBallCreated + this.newBallInterval < newTime) {
        // we can now read from this.player to the the position of where to fire a Ball
            const ball = new Ball(this.player.x, 300)
            this.entities.push(ball)
            this.lastBallCreated = newTime
        }

        // remember current time for next update
        this.lastUpdate = newTime
    }

    draw() {
        this.entities.forEach(entity => entity.draw(this.context))
    }

    cleanup() {
        // to prevent memory leak, don't forget to cleanup dead entities
        this.entities.forEach(entity => {
            if(entity.isDead()) {
                const index = this.entities.indexOf(entity)
                this.entities.splice(index, 1)
            }
        })
    }

    loop() {
        requestAnimationFrame(() => {
            this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height)
            this.update()
            this.draw()
            this.cleanup()
            this.loop()
        })
    }
}

const canvas = document.querySelector('canvas')
const game = new Game(canvas)
game.start()
<canvas height="300" width="300"></canvas>
<script src="script.js"></script>

Una vez que haga clic en "Ejecutar fragmento de código", debe hacer clic en el iframe para enfocarlo y que pueda escuchar las entradas del teclado (flecha izquierda, flecha derecha).

Como beneficio adicional, dado que ahora podemos dibujar y mover la paleta, agregué la capacidad de crear una bola en la misma xcoordenada que la paleta. Puede leer los comentarios que dejé en el fragmento de código anterior para obtener una explicación rápida de cómo funciona esto.


Cómo agregar funcionalidad

Ahora quiero brindarle una perspectiva más general sobre cómo abordar los problemas futuros que pueda tener al invertir en este ejemplo. Tomaré el ejemplo de querer probar la colisión entre dos objetos del juego. Deberías preguntarte dónde colocar la lógica.

  1. ¿Dónde hay un lugar donde todos los objetos del juego pueden compartir lógica? (creando la información)
  2. ¿Dónde necesitará saber acerca de las colisiones? (acceder a la información)

En este ejemplo, todos los objetos del juego son subclases de, Entitypor lo que para mí tiene sentido poner el código allí:

class Entity {
    constructor(x, y) {
        this.collision = 'none'
        this.x = x
        this.y = y
    }

    update() { console.warn(`${this.constructor.name} needs an update() function`) } draw() { console.warn(`${this.constructor.name} needs a draw() function`) }
    isDead() { console.warn(`${this.constructor.name} needs an isDead() function`) } static testCollision(a, b) { if(a.collision === 'none') { console.warn(`${a.constructor.name} needs a collision type`)
            return undefined
        }
        if(b.collision === 'none') {
            console.warn(`${b.constructor.name} needs a collision type`) return undefined } if(a.collision === 'circle' && b.collision === 'circle') { return Math.sqrt((a.x - b.x)**2 + (a.y - b.y)**2) < a.radius + b.radius } if(a.collision === 'circle' && b.collision === 'rect' || a.collision === 'rect' && b.collision === 'circle') { let circle = a.collision === 'circle' ? a : b let rect = a.collision === 'rect' ? a : b // this is a waaaaaay simplified collision that just works in this case (circle always comes from the bottom) const topOfBallIsAboveBottomOfRect = circle.y - circle.radius <= rect.y + rect.height / 2 const bottomOfBallIsBelowTopOfRect = circle.y + circle.radius >= rect.y - rect.height / 2 const ballIsRightOfRectLeftSide = circle.x + circle.radius >= rect.x - rect.width / 2 const ballIsLeftOfRectRightSide = circle.x - circle.radius <= rect.x + rect.width / 2 return topOfBallIsAboveBottomOfRect && bottomOfBallIsBelowTopOfRect && ballIsRightOfRectLeftSide && ballIsLeftOfRectRightSide } console.warn(`there is no collision function defined for a ${a.collision} and a ${b.collision}`)
        return undefined
    }
}

Ahora bien, hay muchos tipos de colisiones 2D, por lo que el código es un poco detallado, pero el punto principal es: esta es una decisión de diseño que estoy tomando aquí. Puedo ser generalista y probar esto en el futuro, pero luego se parece a lo anterior ... Y tengo que agregar una .collisionpropiedad a todos los objetos de mi juego para que sepan si deben tratarse como a 'circle'o a ' rect'en el algoritmo anterior.

class Ball extends Entity {
    constructor(x, y) {
        super(x, y)
        this.collision = 'circle'
    }
    // ...
}

class Paddle extends Entity {
    constructor() {
        super(150, 50)
        this.collision = 'rect'
    }
    // ...
}

O puedo ser minimalista y simplemente agregar lo que necesito, en cuyo caso podría tener más sentido poner el código en la Paddleentidad:

class Paddle extends Entity {
    testBallCollision(ball) {
        const topOfBallIsAboveBottomOfRect = ball.y - ball.radius <= this.y + this.height / 2
        const bottomOfBallIsBelowTopOfRect = ball.y + ball.radius >= this.y - this.height / 2
        const ballIsRightOfRectLeftSide = ball.x + ball.radius >= this.x - this.width / 2
        const ballIsLeftOfRectRightSide = ball.x - ball.radius <= this.x + this.width / 2
        return topOfBallIsAboveBottomOfRect && bottomOfBallIsBelowTopOfRect && ballIsRightOfRectLeftSide && ballIsLeftOfRectRightSide
    }
}

De cualquier manera, ahora tengo acceso a la información de colisión de la cleanupfunción el Gamebucle (donde elegí colocar la lógica de eliminar entidades muertas).

Con mi primera solución generalista , la usaría así:

class Game {
    cleanup() {
        this.entities.forEach(entity => {
            // I'm passing this.player so all entities can test for collision with the player
            if(entity.isDead(this.player)) {
                const index = this.entities.indexOf(entity)
                this.entities.splice(index, 1)
            }
        })
    }
}

class Ball extends Entity {
    isDead(player) {
        // this is the "out of bounds" test we already had
        const outOfBounds = this.y < 0 - this.radius
        // this is the new "collision with player paddle"
        const collidesWithPlayer = Entity.testCollision(player, this)
        return outOfBounds || collidesWithPlayer
    }
}

Con el segundo enfoque minimalista , todavía tendría que pasar al jugador para la prueba:

class Game {
    cleanup() {
        this.entities.forEach(entity => {
            // I'm passing this.player so all entities can test for collision with the player
            if(entity.isDead(this.player)) {
                const index = this.entities.indexOf(entity)
                this.entities.splice(index, 1)
            }
        })
    }
}

class Ball extends Entity {
    isDead(player) {
        // this is the "out of bounds" test we already had
        const outOfBounds = this.y < 0 - this.radius
        // this is the new "collision with player paddle"
        const collidesWithPlayer = player.testBallCollision(this)
        return outOfBounds || collidesWithPlayer
    }
}

Resultado final

Espero que hayas aprendido algo. Mientras tanto, aquí está el resultado final de esta publicación de respuesta muy larga:

class Entity {
    constructor(x, y) {
        this.collision = 'none'
        this.x = x
        this.y = y
    }

    update() { console.warn(`${this.constructor.name} needs an update() function`) }
    draw() { console.warn(`${this.constructor.name} needs a draw() function`) } isDead() { console.warn(`${this.constructor.name} needs an isDead() function`) }

    static testCollision(a, b) {
        if(a.collision === 'none') {
            console.warn(`${a.constructor.name} needs a collision type`) return undefined } if(b.collision === 'none') { console.warn(`${b.constructor.name} needs a collision type`)
            return undefined
        }
        if(a.collision === 'circle' && b.collision === 'circle') {
            return Math.sqrt((a.x - b.x)**2 + (a.y - b.y)**2) < a.radius + b.radius
        }
        if(a.collision === 'circle' && b.collision === 'rect' || a.collision === 'rect' && b.collision === 'circle') {
            let circle = a.collision === 'circle' ? a : b
            let rect = a.collision === 'rect' ? a : b
            // this is a waaaaaay simplified collision that just works in this case (circle always comes from the bottom)
            const topOfBallIsAboveBottomOfRect = circle.y - circle.radius <= rect.y + rect.height / 2
            const bottomOfBallIsBelowTopOfRect = circle.y + circle.radius >= rect.y - rect.height / 2
            const ballIsRightOfRectLeftSide = circle.x + circle.radius >= rect.x - rect.width / 2
            const ballIsLeftOfRectRightSide = circle.x - circle.radius <= rect.x + rect.width / 2
            return topOfBallIsAboveBottomOfRect && bottomOfBallIsBelowTopOfRect && ballIsRightOfRectLeftSide && ballIsLeftOfRectRightSide
        }
        console.warn(`there is no collision function defined for a ${a.collision} and a ${b.collision}`)
        return undefined
    }
}

class Ball extends Entity {
    constructor(x, y) {
        super(x, y)
        this.collision = 'circle'
        this.speed = 300 // px per second
        this.radius = 10 // radius in px
    }

    update({deltaTime}) {
        this.y -= this.speed * deltaTime / 1000 // deltaTime is ms so we divide by 1000
    }

    /** @param {CanvasRenderingContext2D} context */
    draw(context) {
        context.beginPath()
        context.arc(this.x, this.y, this.radius, 0, 2 * Math.PI)
        context.fill()
    }

    isDead(player) {
        const outOfBounds = this.y < 0 - this.radius
        const collidesWithPlayer = Entity.testCollision(player, this)
        return outOfBounds || collidesWithPlayer
    }
}

class Paddle extends Entity {
    constructor() {
        super(150, 50)
        this.collision = 'rect'
        this.speed = 200
        this.width = 50
        this.height = 10
    }

    update({deltaTime, inputs}) {
        this.x += this.speed * deltaTime / 1000 * inputs.direction
    }

    /** @param {CanvasRenderingContext2D} context */
    draw(context) { 
        context.beginPath()
        context.rect(this.x - this.width / 2, this.y - this.height / 2, this.width, this.height)
        context.fill()
    }

    isDead() { return false }
}

class InputsManager {
    constructor() {
        this.direction = 0
        window.addEventListener('keydown', this.onKeydown.bind(this))
        window.addEventListener('keyup', this.onKeyup.bind(this))
    }

    onKeydown(event) {
        switch (event.key) {
            case 'ArrowLeft':
                this.direction = -1
                break
            case 'ArrowRight':
                this.direction = 1
                break
        }
    }

    onKeyup(event) {
        switch (event.key) {
            case 'ArrowLeft':
                if(this.direction === -1) 
                    this.direction = 0
                break
            case 'ArrowRight':
                this.direction = 1
                if(this.direction === 1)
                    this.direction = 0
                break
        }
    }
}

class Game {
    /** @param {HTMLCanvasElement} canvas */
    constructor(canvas) {
        this.entities = [] // contains all game entities (Balls, Paddles, ...)
        this.context = canvas.getContext('2d')
        this.newBallInterval = 500 // ms between each ball
        this.lastBallCreated = -Infinity // timestamp of last time a ball was launched
    }

    start() {
        this.lastUpdate = performance.now()
        this.player = new Paddle()
        this.entities.push(this.player)
        this.inputsManager = new InputsManager()
        this.loop()
    }

    update() {
        // calculate time elapsed
        const newTime = performance.now()
        const deltaTime = newTime - this.lastUpdate

        // update every entity
        const frameData = {
            deltaTime,
            inputs: this.inputsManager,
        }
        this.entities.forEach(entity => entity.update(frameData))

        // other update logic (here, create new entities)
        if(this.lastBallCreated + this.newBallInterval < newTime) {
            const ball = new Ball(this.player.x, 300)
            this.entities.push(ball)
            this.lastBallCreated = newTime
        }

        // remember current time for next update
        this.lastUpdate = newTime
    }

    draw() {
        this.entities.forEach(entity => entity.draw(this.context))
    }

    cleanup() {
        // to prevent memory leak, don't forget to cleanup dead entities
        this.entities.forEach(entity => {
            if(entity.isDead(this.player)) {
                const index = this.entities.indexOf(entity)
                this.entities.splice(index, 1)
            }
        })
    }

    loop() {
        requestAnimationFrame(() => {
            this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height)
            this.update()
            this.draw()
            this.cleanup()
            this.loop()
        })
    }
}

const canvas = document.querySelector('canvas')
const game = new Game(canvas)
game.start()
<canvas height="300" width="300"></canvas>
<script src="script.js"></script>

Una vez que haga clic en "Ejecutar fragmento de código", debe hacer clic en el iframe para enfocarlo y que pueda escuchar las entradas del teclado (flecha izquierda, flecha derecha).