Creating web graphics

This is an example of a basic interactive graphic: some balls, some walls, and some falls collisions:

You control the lightest coloured (and technically biggest!) ball, and you can shoot it in the direction of wherever you click/tap in the graphic. The further away from the ball you click/tap, the faster it'll go.

There is also a slider for controlling the "board friction". The lower it is, the longer balls will keep running, and keep running into the walls and each other!

Let's learn how to make this!

Graphics progrmaming can be daunting, but as long as we can break down out problem into distinct pieces of work, as well as simple pieces that we can then generalise, things actually become a lot easier. For example, let's start by just creating a box with four walls, and a single ball... that just sit there. No interaction yet, just "let's draw a picture":

let ball;
const walls = [];

function setup() {
  setSize(600, 400);
  
  walls.push(
    new Line(0, 0, width, 0),
    new Line(width, 0, width, height),
    new Line(width, height, 0, height),
    new Line(0, height, 0, 0),
  );
  
  ball = new Ball(random(width), random(height));
}

function draw() {
  clear(`white`);
  for (wall of walls) wall.draw();
  ball.draw();
}

Of course, this relies on two classes that we haven't defined yet, so let's get those sorted out:

class Line {
  constructor(x1, y1, x2, y2, c = `black`) {
    Object.assign(this, { x1, y1, x2, y2, c });
  }

  draw() {
    setStroke(this.c);
    line(this.x1, this.y1, this.x2, this.y2);
  }
}

class Ball {
  constructor(x, y, c = `red`, r = 15) {
    Object.assign(this, { x, y, c, r });
  }

  draw() {
    setStroke(`black`);
    setFill(this.c);
    circle(this.x, this.y, this.r);
  }
}

Let's make this a real graphic!

Hmm... it works, but we've put our walls at the edge of the "screen" so we can't really see them... let's add a little bit of padding to make things easier to see, and then also update our ball so we can't spawn outside our "box":

let ball;
const walls = [];
const padding = 20;

function setup() {
  setSize(600, 400);

  const p = padding;
  walls.push(
    new Line(p, p, width-p, p),
    new Line(width-p, p, width-p, height-p),
    new Line(width-p, height-p, p, height-p),
    new Line(p, height-p, p, p),
  );
  
  ball = new Ball(random(p,width-p), random(p,height-p));
}

Nothing too crazy, but at least now things make more sense:

There's a small problem left though: if you click the reset button a few times, you might find that our ball overlaps a wall. It's still "inside" the box if we look at its center point, but that's not really how balls work... let's make sure we take the ball's radius into account, too:

function setup() {
  ...

  const radius = 15;
  const r = p + radius/2;
  ball = new Ball(random(r,width-r), random(r,height-r), `red`, radius);
}

And that solves that problem.

Adding some speed

With the basics done (hah. "done") let's add some actual speed into the mix: it's time to give our ball a kick and see what happens. This will require adding a pointer listener (the "pointer" being either a mouse or a finger, so that things work on desktop and mobile):

...
function setup() {
  ...
  play();
}

function draw() {
  ...

  ball.update();
}

function pointerDown(x, y) {
  ball.shoot(x - ball.x, y - ball.y);
}

class Ball {
  vx=0;
  vy=0;

  ...

  shoot(vx, vy) {
    this.vx += vx;
    this.vy += vy;   
  }

  update() {
    this.x += this.vx;
    this.y += this.vy;
    this.vx *= 0.9;
    this.vy *= 0.9;
    if (abs(this.vx) < 0.001) this.vx = 0;
    if (abs(this.vy) < 0.001) this.vy = 0;
  }
}

What did we add here? First off, the ball changes: we added an "x" and "y" speed, which we use to update the position of the ball every time update() gets called. And when it is, we also reduce those speeds a little until we've updated it so many times that they're basically zero. At which point we explicitly set them to zero so we don't end up adding values that are basically meaningless over and over.

Then, we also add the shoot function that lets us gave the ball some speed in the x and/or y direction, and then we call that in our pointer handler, where we base the speeds directly off of how far from the ball we're clicking/touching the screen. The further away, the larger the speed values, and so the faster it'll shoot in a direction and the longer it'll keep "rolling".

...actually hold up, "keep rolling"? We haven't implemented any collision detection for our walls yet! Let's quickly add something that just stops the ball dead in its track when it hits a wall:

...

function draw() {
  ...

  const { x, y, r } = ball;
  if (x+r/2 > padding && x+r/2 < width-padding && y+r/2 > padding && y+r/2 < height-padding) {
    ball.update();
  }
}

There. It's not collision detection, we just don't update the ball's position anymore if it goes past our walls. Let's see what happens if we click in our updated graphics area to make the ball shoot off in some direction!

...aaaaand what happens is that the ball moves so fast we can barely see it go. And it doesn't even get stuck on walls most of the time because it's going so fast that one one frame it might be "miles" in front of a wall, but the very next frame already be miles past a wall. We'll never even detect that a fake collision happened! We need to click super-close to the ball just to make it move at a reasonable pace. None of this is is great!

Implementing wall bounces

Time to just do this propely: wall collions are based on whether or not a ball's trajectory passed through a wall or not. We can't just look at where a ball is at any given frame, we need to know the entire path it takes from one frame to the next, and if that passes through a wall, we need to "bounce" the path off the wall instead.

So let's reduce the complexity of our setup: instead of an entire box, let's set up a single wall in the middle of the screen, and then we'll update our ball so that it doesn't immediate shoot off into infity by scaling down how much the shooting code actually adds to the ball's speed values.

function setup() {
  setSize(600, 400);
  walls.push(new Line(width/2, 100, width/2, height-100));
  ball = new Ball(width*0.75, height/2);
  play();
}

function draw() {
  clear(`white`);
  walls[0].draw();
  ball.draw();
  ball.update(walls);
}

...

class Ball {
  ...

  shoot(vx, vy) {
    this.vx += vx/10;
    this.vy += vy/10;
  }

  update(walls) {
    const { x, y, vx, vy } = this;
    this.prev = { x, y, vx, vy };

    ...

    for (const w of walls) {
      w.resolveCollision(this);
    }
  }
}

Let's review what we changed here, before we start implementing the collision detection for walls. First off, the obvious "one wall" in our setup, and we're just putting the ball to the right of that. Fewer random properties make it easier to test our code. Then, when we update our ball's position we also pass in "all" the walls which in this case is of course just a single wall. In the Ball class, we've reduced the speed by a factor 10 any time we shoot the ball, so that should make things a lot easier to see, and then finally in the update function we cache the ball's properties prior to running the actual update, so that when we go check for wall collisions we have both the old and new positions. Remember: we need to check whether the trajectory of the ball passes through a wall, so we need two points.

Which means it's time to update our Line class:

class Line {
  ...

  resolveCollision(ball) {
    const { x1, y1, x2, y2 } = this;
    const { x:bx2, y:by2, prev } = ball;
    const { x:bx1, y:by1 } = prev;

    // ...how do we actually resolve collisions?
  }
}

That's... both very short and very not done: how DO we resolve collisions? Our ball moves along a straight line, so we might consider using line/line intersection tests, and then if there's an intersection, we "bounce" the ball, but we'll miss a whole bunch of possible collisions that way. For example, if we shoot the ball "past" the line, but less that the ball's radius away from it, "some" part of the ball's going to hit it.

We could try to use a signed distance field: instead of the line, we consider the entire outline that is traced if we run our ball along the line: every point on that outline is a point where, if that's the center of our ball, we're touching the line somewhere. So now we can perform a path intersection test, but instead of a single line/line test, we may need a line/line test and two line/circle intersection tests.

And... we'll continue here, later.