Skip to main content

具有曲线检测功能的物理模拟器

说明:用鼠标光标抛掷小球。

import Konva from 'konva';

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

/*
 * 向量数学函数
 */
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,
  };
}
/*
 * 该叉积函数已简化,
 * 由于向量 a 和 b 位于画布平面,
 * 因此 x 和 y 设为零
 */
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;
  // 像素
  var totalX = 0;
  var totalY = 0;
  var x = ball.x();
  var y = ball.y();
  /*
   * 检查中心点周围的多个点,
   * 以确定法向量
   */
  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% 能量损失
  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();

  // 物理变量
  var gravity = 10;
  // 像素/秒²
  var speedIncrementFromGravityEachFrame = (gravity * timeDiff) / 1000;
  var collisionDamper = 0.2;
  // 20% 能量损失
  var floorFriction = 5;
  // 像素/秒²
  var floorFrictionSpeedReduction = (floorFriction * timeDiff) / 1000;

  // 如果球被拖拽
  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 {
    // 重力
    ball.velocity.y += speedIncrementFromGravityEachFrame;
    x += ball.velocity.x;
    y += ball.velocity.y;

    // 天花板碰撞条件
    if (y < radius) {
      y = radius;
      ball.velocity.y *= -1;
      ball.velocity.y *= 1 - collisionDamper;
    }

    // 地板碰撞条件
    if (y > height - radius) {
      y = height - radius;
      ball.velocity.y *= -1;
      ball.velocity.y *= 1 - collisionDamper;
    }

    // 地板摩擦
    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;
      }
    }

    // 右墙碰撞条件
    if (x > width - radius) {
      x = width - radius;
      ball.velocity.x *= -1;
      ball.velocity.x *= 1 - collisionDamper;
    }

    // 左墙碰撞条件
    if (x < radius) {
      x = radius;
      ball.velocity.x *= -1;
      ball.velocity.x *= 1 - collisionDamper;
    }

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

    /*
     * 如果球与曲线接触,则沿
     * 曲线表面法线方向反弹
     */
    var collision = handleCurveCollision(ball, curve);
  }
}

// 创建舞台
const stage = new Konva.Stage({
  container: 'container',
  width: width,
  height: height,
});

// 创建曲线和球的独立图层
const curveLayer = new Konva.Layer();
const ballLayer = new Konva.Layer();

// 使用原始贝塞尔曲线创建曲线
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);

// 创建原样式球
const ball = new Konva.Circle({
  x: 190,
  y: 20,
  radius: 20,
  fill: 'blue',
  draggable: true,
  opacity: 0.8,
});

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

// 添加原事件处理器
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);

// 按正确顺序添加图层到舞台
stage.add(curveLayer);
stage.add(ballLayer);

// 添加原样式 tween 动画
const tween = new Konva.Tween({
  node: ball,
  fill: 'red',
  duration: 0.3,
  easing: Konva.Easings.EaseOut,
});

// 添加动画
const anim = new Konva.Animation(function (frame) {
  updateBall(frame);
}, ballLayer);

anim.start();