Skip to main content

Physics Simulator with Curve Detection

Instructions: Throw the ball around with your cursor.

import Konva from 'konva';

const width = window.innerWidth;
const height = window.innerHeight;

/*
 * Vector math functions
 */
function dot(a, b) {
  return a.x * b.x + a.y * b.y;
}
function magnitude(a) {
  return Math.sqrt(a.x * a.x + a.y * a.y);
}
function normalize(a) {
  var mag = magnitude(a);

  if (mag === 0) {
    return {
      x: 0,
      y: 0,
    };
  } else {
    return {
      x: a.x / mag,
      y: a.y / mag,
    };
  }
}
function add(a, b) {
  return {
    x: a.x + b.x,
    y: a.y + b.y,
  };
}
function angleBetween(a, b) {
  return Math.acos(dot(a, b) / (magnitude(a) * magnitude(b)));
}
function rotate(a, angle) {
  var ca = Math.cos(angle);
  var sa = Math.sin(angle);
  var rx = a.x * ca - a.y * sa;
  var ry = a.x * sa + a.y * ca;
  return {
    x: rx * -1,
    y: ry * -1,
  };
}
function invert(a) {
  return {
    x: a.x * -1,
    y: a.y * -1,
  };
}
/*
 * this cross product function has been simplified by
 * setting x and y to zero because vectors a and b
 * lie in the canvas plane
 */
function cross(a, b) {
  return {
    x: 0,
    y: 0,
    z: a.x * b.y - b.x * a.y,
  };
}
function getNormal(curve, ball) {
  var curveLayer = curve.getLayer();
  var context = curveLayer.getContext();
  var testRadius = 20;
  // pixels
  var totalX = 0;
  var totalY = 0;
  var x = ball.x();
  var y = ball.y();
  /*
   * check various points around the center point
   * to determine the normal vector
   */
  for (var n = 0; n < 20; n++) {
    var angle = (n * 2 * Math.PI) / 20;
    var offsetX = testRadius * Math.cos(angle);
    var offsetY = testRadius * Math.sin(angle);
    var testX = x + offsetX;
    var testY = y + offsetY;
    if (!context._context.isPointInPath(testX, testY)) {
      totalX += offsetX;
      totalY += offsetY;
    }
  }

  var normal;

  if (totalX === 0 && totalY === 0) {
    normal = {
      x: 0,
      y: -1,
    };
  } else {
    normal = {
      x: totalX,
      y: totalY,
    };
  }

  return normalize(normal);
}
function handleCurveCollision(ball, curve) {
  var curveLayer = curve.getLayer();
  var x = ball.x();
  var y = ball.y();

  var curveDamper = 0.05;
  // 5% energy loss
  if (curveLayer.getIntersection({ x: x, y: y })) {
    var normal = getNormal(curve, ball);
    if (normal !== null) {
      var angleToNormal = angleBetween(normal, invert(ball.velocity));
      var crossProduct = cross(normal, ball.velocity);
      var polarity = crossProduct.z > 0 ? 1 : -1;
      var collisonAngle = polarity * angleToNormal * 2;
      var collisionVector = rotate(ball.velocity, collisonAngle);

      ball.velocity.x = collisionVector.x;
      ball.velocity.y = collisionVector.y;
      ball.velocity.x *= 1 - curveDamper;
      ball.velocity.y *= 1 - curveDamper;

      x += normal.x;
      if (ball.velocity.y > 0.1) {
        y += normal.y;
      } else {
        y += normal.y / 10;
      }
      ball.x(x).y(y);
    }

    tween.finish();
  }
}
function updateBall(frame) {
  var timeDiff = frame.timeDiff;
  var stage = ball.getStage();
  var height = stage.height();
  var width = stage.width();
  var x = ball.x();
  var y = ball.y();
  var radius = ball.radius();

  tween.reverse();

  // physics variables
  var gravity = 10;
  // px / second^2
  var speedIncrementFromGravityEachFrame = (gravity * timeDiff) / 1000;
  var collisionDamper = 0.2;
  // 20% energy loss
  var floorFriction = 5;
  // px / second^2
  var floorFrictionSpeedReduction = (floorFriction * timeDiff) / 1000;

  // if ball is being dragged and dropped
  if (ball.isDragging()) {
    var mousePos = stage.getPointerPosition();

    if (mousePos) {
      var mouseX = mousePos.x;
      var mouseY = mousePos.y;

      var c = 0.06 * timeDiff;
      ball.velocity = {
        x: c * (mouseX - ball.lastMouseX),
        y: c * (mouseY - ball.lastMouseY),
      };

      ball.lastMouseX = mouseX;
      ball.lastMouseY = mouseY;
    }
  } else {
    // gravity
    ball.velocity.y += speedIncrementFromGravityEachFrame;
    x += ball.velocity.x;
    y += ball.velocity.y;

    // ceiling condition
    if (y < radius) {
      y = radius;
      ball.velocity.y *= -1;
      ball.velocity.y *= 1 - collisionDamper;
    }

    // floor condition
    if (y > height - radius) {
      y = height - radius;
      ball.velocity.y *= -1;
      ball.velocity.y *= 1 - collisionDamper;
    }

    // floor friction
    if (y == height - radius) {
      if (ball.velocity.x > 0.1) {
        ball.velocity.y -= floorFrictionSpeedReduction;
      } else if (ball.velocity.x < -0.1) {
        ball.velocity.x += floorFrictionSpeedReduction;
      } else {
        ball.velocity.x = 0;
      }
    }

    // right wall condition
    if (x > width - radius) {
      x = width - radius;
      ball.velocity.x *= -1;
      ball.velocity.x *= 1 - collisionDamper;
    }

    // left wall condition
    if (x < radius) {
      x = radius;
      ball.velocity.x *= -1;
      ball.velocity.x *= 1 - collisionDamper;
    }

    ball.position({ x: x, y: y });

    /*
     * if the ball comes into contact with the
     * curve, then bounce it in the direction of the
     * curve's surface normal
     */
    var collision = handleCurveCollision(ball, curve);
  }
}

// create stage
const stage = new Konva.Stage({
  container: 'container',
  width: width,
  height: height,
});

// create separate layers for curve and ball
const curveLayer = new Konva.Layer();
const ballLayer = new Konva.Layer();

// create curve with original bezier curve
const curve = new Konva.Shape({
  sceneFunc: function (context) {
    context.beginPath();
    context.moveTo(40, height);
    context.bezierCurveTo(
      width * 0.2,
      -1 * height * 0.5,
      width * 0.7,
      height * 1.3,
      width,
      height * 0.5
    );
    context.lineTo(width, height);
    context.lineTo(40, height);
    context.closePath();
    context.fillShape(this);
  },
  fill: '#8dbdff',
});

curveLayer.add(curve);

// create ball with original styling
const ball = new Konva.Circle({
  x: 190,
  y: 20,
  radius: 20,
  fill: 'blue',
  draggable: true,
  opacity: 0.8,
});

ball.velocity = {
  x: 0,
  y: 0,
};

// add original event handlers
ball.on('dragstart', function () {
  ball.velocity = {
    x: 0,
    y: 0,
  };
  anim.start();
});

ball.on('mousedown', function () {
  anim.stop();
});

ball.on('mouseover', function () {
  document.body.style.cursor = 'pointer';
});

ball.on('mouseout', function () {
  document.body.style.cursor = 'default';
});

ballLayer.add(ball);

// add layers to stage in correct order
stage.add(curveLayer);
stage.add(ballLayer);

// add tween with original styling
const tween = new Konva.Tween({
  node: ball,
  fill: 'red',
  duration: 0.3,
  easing: Konva.Easings.EaseOut,
});

// add animation
const anim = new Konva.Animation(function (frame) {
  updateBall(frame);
}, ballLayer);

anim.start();