Search code examples
javascriptanimationhtml5-canvasprototypejavascript-objects

How can I animate a ball with a background image on the canvas?


I'm creating a canvas game that has balls that bounce off each other. I would like the balls to have their own skins where a background image is put onto the arc element.

When the ball isn't bouncing, the image clips just fine and is circular, like the arc. However, when I start to animate the ball, it simply wouldn't move at all because the clip function doesn't allow the image or the arc to be redrawn.

That's when I discovered the save and restore functions in the canvas that have allowed the clip method to be used while animating.

The issue with this is that part of the image doesn't clip properly. Only half of the animation is circular and the other half is the rectangular image. I've tried adjusting the position of the image but this did not lead to the desired result.

I'm not sure why the code is behaving like this and how to fix it so that it's simply an animating ball with an image background.

If someone has some insight into this and perhaps a solution, that would be very much appreciated.

Here is the code and a snippet below:

  const x = document.getElementById('canvas');
  const ctx = x.getContext('2d');
  let slide = 0;
  class Balls {
    constructor(xPos, yPos, radius) {
      this.xPos = xPos;
      this.yPos = yPos;
      this.radius = radius;
      this.imgX = this.xPos - this.radius;
    }
  }
  const img = document.createElement('img');
  img.src = 'https://geology.com/google-earth/google-earth.jpg';
  Balls.prototype.render = function() {
    ctx.save();
    ctx.arc(this.xPos, this.yPos, this.radius, 0, Math.PI * 2);
    ctx.clip();
    ctx.drawImage(img, this.imgX, this.yPos - this.radius, this.radius * 2, this.radius * 2);

  };
  Balls.prototype.motion = function() {
    this.imgX = this.imgX + 1;
    this.xPos = this.xPos + 1;
  }
  let object = new Balls(100, 100, 25);
  const animate = () => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    object.render();
    object.motion();
    ctx.restore();
  }
  setInterval(animate, 50);
body {
  background-color: grey;
}

#canvas {
  background-color: white;
}

#mod {
  border-radius: 100%
}
<!DOCTYPE html>
<html>
<head>
  <title>Page Title</title>
  <script src='practice.js'></script>
  <link rel="stylesheet" type="text/css" href="practice.css">
</head>
<body>
  <canvas id="canvas" height="200" width="800" />
</body>
</html>


Solution

  • You need to call ctx.beginPath(), otherwise, every call to arc() are added to the same and only sub-path. This means that at the end, you are clipping a weird Path made from a lot of arcs: ASCII representation of the Path after 5 frames : ((((( )

    const x = document.getElementById('canvas');
    const ctx = x.getContext('2d');
    let slide = 0;
    class Balls {
      constructor(xPos, yPos, radius) {
        this.xPos = xPos;
        this.yPos = yPos;
        this.radius = radius;
        this.imgX = this.xPos - this.radius;
      }
    }
    const img = document.createElement('img');
    img.src = 'https://geology.com/google-earth/google-earth.jpg';
    Balls.prototype.render = function() {
      ctx.save();
      // begin a  new sub-path
      ctx.beginPath();
      ctx.arc(this.xPos, this.yPos, this.radius, 0, Math.PI * 2);
      ctx.clip();
      ctx.drawImage(img, this.imgX, this.yPos - this.radius, this.radius * 2, this.radius * 2);
    
    };
    Balls.prototype.motion = function() {
      this.imgX = this.imgX + 1;
      this.xPos = this.xPos + 1;
    }
    let object = new Balls(100, 100, 25);
    const animate = () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      object.render();
      object.motion();
      ctx.restore();
      requestAnimationFrame(animate);
    }
    animate();
    body {
      background-color: grey;
    }
    
    #canvas {
      background-color: white;
    }
    
    #mod {
      border-radius: 100%
    }
    <canvas id="canvas" height="200" width="800" />

    Also note that for circle masking, you would be better to use compositing than clipping, compositing handles better antialiasing, and doesn't require expensive save/restore.

    const x = document.getElementById('canvas');
    const ctx = x.getContext('2d');
    let slide = 0;
    class Balls {
      constructor(xPos, yPos, radius) {
        this.xPos = xPos;
        this.yPos = yPos;
        this.radius = radius;
        this.imgX = this.xPos - this.radius;
      }
    }
    const img = document.createElement('img');
    img.src = 'https://geology.com/google-earth/google-earth.jpg';
    Balls.prototype.render = function() {
      // begin a  new sub-path
      ctx.beginPath();
      ctx.arc(this.xPos, this.yPos, this.radius, 0, Math.PI * 2);
      ctx.fill();
      ctx.globalCompositeOperation = 'source-in';
      ctx.drawImage(img, this.imgX, this.yPos - this.radius, this.radius * 2, this.radius * 2);
      ctx.globalCompositeOperation = 'source-over';
    };
    Balls.prototype.motion = function() {
      this.imgX = this.imgX + 1;
      this.xPos = this.xPos + 1;
    }
    let object = new Balls(100, 100, 25);
    const animate = () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      object.render();
      object.motion();
      requestAnimationFrame(animate);
    }
    animate();
    body {
      background-color: grey;
    }
    
    #canvas {
      background-color: white;
    }
    
    #mod {
      border-radius: 100%
    }
    <canvas id="canvas" height="200" width="800" />