Executando vários loops requestAnimation para disparar várias bolas?

Oct 15 2020

Estou tentando fazer com que a bola abaixo continue aparecendo e disparando através do eixo y em um intervalo definido e sempre de onde está a posição x do remo (mouse), preciso que haja um atraso entre cada disparo da bola. Estou tentando fazer invasores do espaço, mas com a bola constantemente disparando em um intervalo definido.

Preciso criar vários loops requestAnimationFrame para cada bola? Alguém pode ajudar com um exemplo muito básico de como isso deve ser feito, por favor ou vincular um bom artigo? Estou preso na criação de uma matriz para cada bola e não tenho certeza de como arquitetar o loop para obter esse efeito. Todos os exemplos que posso encontrar são muito complexos


<!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>

Respostas

2 Sheraff Oct 18 2020 at 15:32

Princípios básicos

Esta é uma maneira de fazer isso:

  1. você precisa de um Gameobjeto que irá lidar com a lógica de atualização, armazenar todas as entidades atuais, lidar com o loop do jogo ... IMO, é aqui que você deve acompanhar quando foi Balldisparado pela última vez e se deve disparar um novo.

    Nesta demonstração, este objeto também lida com o tempo atual, o tempo delta e a solicitação de quadros de animação, mas alguns podem argumentar que essa lógica pode ser externalizada e apenas chamar algum tipo de Game.update(deltaTime)em cada quadro.


  1. você precisa de objetos diferentes para todas as entidades em seu jogo. Eu criei uma Entityclasse porque quero ter certeza de que todas as entidades do jogo têm o mínimo necessário para funcionar (ou seja, atualizar, desenhar, x, y ...).

    Existe uma Ballclasse que extends Entityé responsável por conhecer os seus próprios parâmetros (velocidade, tamanho, ...), como se atualizar e desenhar, ...

    Há uma Paddleaula que deixei vazia para você concluir.


O resultado final é que tudo é uma questão de separação de interesses. Quem deve saber o quê sobre quem? E, em seguida, passe as variáveis.


Quanto à sua outra pergunta:

Preciso criar vários loops requestAnimationFrame para cada bola?

É definitivamente possível, mas eu diria que ter um lugar centralizado que alças lastUpdate, deltaTime, lastBallCreatedtorna as coisas muito mais simples. E, na prática, os desenvolvedores tendem a tentar ter um único loop de quadro de animação para isso.

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>


Gerenciando entradas do jogador

Agora, digamos que você queira adicionar entradas de teclado ao jogo. Nesse caso, eu criaria uma classe separada, porque dependendo de quantos "botões" você deseja oferecer suporte, pode ficar muito complicado muito rápido.

Então, primeiro, vamos desenhar uma raquete básica para que possamos ver o que está acontecendo:

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 }
}

E agora adicionamos uma InputsManagerclasse básica que você pode complicar o quanto quiser. Só por duas chaves, manipulação keydowne keyupe o fato de que duas teclas podem ser pressionadas de uma vez ele já algumas linhas de código, por isso é bom manter as coisas separadas, de modo a não sobrecarregar o nosso 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
        }
    }
}

Agora, podemos atualizar nossa Gameclasse para fazer uso deste novoInputsManager

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))
    }

    // ...

}

E depois de atualizar o código dos updatemétodos de nossas entidades para realmente usar o novo InputsManager, aqui está o 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>

Depois de clicar em "Executar trecho de código", você deve clicar no iframe para focalizá-lo para que possa ouvir as entradas do teclado (seta para a esquerda, seta para a direita).

Como um bônus, já que agora podemos desenhar e mover a raquete, adicionei a capacidade de criar uma bola na mesma xcoordenada da raquete. Você pode ler os comentários que deixei no trecho de código acima para uma explicação rápida de como isso funciona.


Como adicionar funcionalidade

Agora, quero dar uma visão mais geral de como abordar problemas futuros que você possa ter ao investir neste exemplo. Vou pegar o exemplo de querer testar a colisão entre dois objetos do jogo. Você deve se perguntar onde colocar a lógica?

  1. onde é um lugar onde todos os objetos do jogo podem compartilhar a lógica? (criando a informação)
  2. onde você precisará saber sobre as colisões? (acessando as informações)

Neste exemplo, todos os objetos do jogo são subclasses de, Entityentão, para mim, faz sentido colocar o código lá:

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
    }
}

Agora, há muitos tipos de colisões 2D, então o código é um pouco prolixo, mas o ponto principal é: esta é uma decisão de design que estou tomando aqui. Posso ser generalista e à prova de futuro isso, mas então se parece com o acima ... E eu tenho que adicionar uma .collisionpropriedade a todos os meus objetos de jogo para que eles saibam se devem ser tratados como a 'circle'ou a ' rect'no algoritmo acima.

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

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

Ou posso ser minimalista e apenas adicionar o que preciso; nesse caso, pode fazer mais sentido colocar o código na Paddleentidade:

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 qualquer forma, agora tenho acesso às informações de colisão da cleanupfunção Gameloop (onde escolhi colocar a lógica de remoção de entidades mortas).

Com minha primeira solução generalista , eu usaria assim:

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
    }
}

Com a segunda abordagem minimalista , eu ainda teria que passar o jogador para o teste:

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 você tenha aprendido algo. Nesse meio tempo, aqui está o resultado final desta postagem de resposta muito longa:

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>

Depois de clicar em "Executar trecho de código", você deve clicar no iframe para focalizá-lo para que possa ouvir as entradas do teclado (seta para a esquerda, seta para a direita).