开始
从Canvas开始实现一个2D的球碰撞模型
球
第一步就是创造球。创建的球需要有大小, 并且具有位置信息。
interface Ball {
x: number;
y: number;
r: number;
}
class Ball implements Ball {
constructor(x = 10, y = 10, r = 10) {
this.x = x;
this.y = y;
this.r = r;
}
}
要一个Canvas类来执行绘制等操作。
interface Canvas {
$canvas: HTMLCanvasElement;
}
class Canvas implements Canvas {
constructor (canvasElement) {
this.$canvas = canvasElement;
}
drawCircle(ball:Ball) {
const ctx = this.$canvas.getContext("2d");
ctx?.beginPath();
ctx?.arc(ball.x, ball.y, ball.r, 0, 2 * Math.PI);
ctx?.stroke();
}
}
创建一个Canvas:
const canvas = new Canvas(el);
const ball = new Ball(20, 20, 15);
canvas.drawCircle(ball);
出现了一个圆圈,为球添加一个颜色属性, 然后把它填充起来
运动的球
接下来就是要让球动起来。所有动画的实现原理基本都相同:快速播放连续的静态画面, 在肉眼中获得连续的效果就是动画。每一个静态画面成为一个帧。只需要设定静态画面的生成规则, 然后去绘制就行。
“运动规则”:这需要一点基础的物理和数学知识。使用向量来描述运动状态。实现一个向量类, 其中包含一些向量的常用方法。
class Vector implements Vectors {
x: number
y: number
constructor(x, y) {
this.x = x;
this.y = y;
}
add(vector: Vectors): Vectors {
return new Vector(this.x + vector.x, this.y + vector.y);
}
subtract(vector: Vectors): Vectors {
return new Vector(this.x - vector.x, this.y - vector.y)
}
multiply(scalar: number): Vectors {
return new Vector(this.x * scalar, this.y * scalar);
}
dotProduct(vector: Vectors): number {
return this.x * vector.x + this.y * vector.y;
}
get direction() {
return Math.atan2(this.x, this.y);
}
}
在Ball中新增position属性,用向量表示。当然仅仅有位置是不够的,描述运动还需要更多的属性, 例如速度,所以同时我们新增另一个属性velocity。
class Ball implements Balls {
x: number
y: number
r: number
position: Vectors
velocity: Vectors
color: string
constructor(x = 10, y = 10, r = 10, color = "red", velocity: Vectors) {
this.x = x;
this.y = y;
this.position = new Vector(x, y);
this.velocity = velocity;
this.r = r;
this.color = color;
}
}
这个构造函数要接收的参数越来越多,为了方便处理, 简化传入一个对象。
class Ball implements Balls {
x: number = 10
y: number = 10
r: number = 10
position: Vectors = new Vector(this.x, this.y)
velocity: Vectors | null = null
color: string = "#FF0000"
constructor(config: Balls) {
Object.assign(this, config, {
position: new Vector(config.x, config.y)
});
}
}
现在球已经拥有了动画所需的所有属性,接下来就要进行动画的绘制。为了方便管理绘制,笔者准备实现一个控制器, 用来控制画布中的元素更新, Controller控制要显示的画布和其中的运动图形, 控制他们的更新。
class Controller {
canvas: CanvasI
elms: Balls[]
constructor(canvas: CanvasI, elms: Balls[]) {
this.canvas = canvas;
this.elms = elms;
}
update(timer) {
/**
* 为elm生成唯一的更新ID
*/
const updateId = Math.floor(Math.random() * 1000);
const elems = this.elms.map((elm)=> {
return elm.update && elm.update(this, timer, updateId);
});
return new Controller(this.canvas, elems)
}
}
controller用于绘制每个元素的状态, 所以我们为每个元素, 也就是ball添加update方法, 用以更新其状态。
class Ball implements Balls {
/**............. */
/**
*
* @param ctrl 控制器
* @param timer 时间
* @param updateId 更新ID
*/
update(ctrl: Controllers, timer: number, updateId: number) {
//球位置碰触到画布左右边界
if (this.position.x + this.r >= ctrl.canvas.$canvas.width || this.position.x - this.r <= 0) {
this.velocity = new Vector(-this.velocity.x, this.velocity.y)
}
//球的位置碰触到画布上下边界
if (this.position.y + this.r >= ctrl.canvas.$canvas.height || this.position.y - this.r <= 0) {
this.velocity = new Vector(this.velocity.x, -this.velocity.y);
}
return Object.assign(this, {position: this.position.add(this.velocity)})
}
}
动画就是一帧一帧的图像绘制, 所以要为canvas提供同步的绘制方法, 以便更新所有元素的位置。
class Canvas implements CanvasI {
/* ...... */
sync(balls: Balls[]) {
this.clearCanvas();
this.updateSync(balls)
}
clearCanvas() {
const ctx = this.$canvas.getContext('2d');
ctx?.clearRect(0, 0, this.$canvas.width, this.$canvas.height);
}
updateSync(balls:Balls[]) {
balls.forEach((ball) => this.drawCircle(ball));
}
}
碰撞检测
开始绘制更多球。处理画布的边界情况是简单的, 但是如果多个球之间有相互作用, 就会变得略微复杂。首先要知道不同的球之间是否发生了碰撞。 碰撞检测的原理:判断两个球的圆心之间的距离小于等于两个球的半径之和, 在球的update方法中加入对应地检测代码。
class Ball implements Balls {
/*.........*/
/**
*
* @param ctrl 控制器
* @param timer 时间
* @param updateId 更新ID
*/
update(ctrl: Controllers, timer: number, updateId: number) {
//球位置碰触到画布左右边界
if (this.position.x + this.r >= ctrl.canvas.$canvas.width || this.position.x - this.r <= 0) {
this.velocity = new Vector(-this.velocity.x, this.velocity.y)
}
//球的位置碰触到画布上下边界
if (this.position.y + this.r >= ctrl.canvas.$canvas.height || this.position.y <= 0) {
this.velocity = new Vector(this.velocity.x, -this.velocity.y);
}
//碰撞检测
ctrl.elms.forEach((item: Balls) => {
if (item === this) return;
//俩圆心之间的距离
const distance = Math.sqrt((this.position.x - item.position.x) ** 2 + (this.position.y - item.position.y) ** 2);
if (distance <= this.r + item.r) {
//更换颜色以标识碰撞发生
this.color = this.color === "#FF0000" ? "#00FF00" : "#FF0000";
item.color = item.color === "#FF0000" ? "#00FF00" : "#FF0000";
}
})
return Object.assign(this, {position: this.position.add(this.velocity)})
}
}
创建多个球,赋予随机的速度和位置,观察碰撞检测的正确性。
每当两颗球检测到碰撞时, 让球的颜色发生红绿之间的变化。仔细观察能够看出, 获得了正确的碰撞检测结果。
碰撞检测算法: 每次对每个球进行位置更新判断时, 依次和其它球进行比较计算, 从而确定碰撞状态, 这里的时间复杂度是O(n^2), 这在球的个数比较少时没有什么问题, 不过这也意味着随着球的数量增加, 更新所花费的时间将会大幅度增加, 这里有一个性能隐患,有待进一步优化。
添加弹性碰撞
为了简化模型, 使用弹性碰撞,从而避免摩擦力,碰撞损失等问题带来的更高的复杂度。
弹性斜碰满足碰撞方向分动量守恒, 总动量守恒和能量守恒。我们能够直接获得矢量表示的弹性斜碰方程如下:
尖括号表示向量的内积, 双竖线表示向量的长度。
从上面的公式中能够看到, 碰撞的计算和质量有明显的关系, 因此我们要为ball添加上质量。依旧是为了简化模型, 假设每个球的密度相同且恒定且均匀, 这样我们就可以“直接”用面积来表示质量。
class Ball implements Balls {
/*....... */
get weight() {
return (Math.PI * this.r) ** 2;
}
}
然后我们需要将碰撞公式用函数表示出来:
//球的弹性斜碰公式
const collision = (ball1: Balls, ball2: Balls) => {
//圆心位置之差(x1-x2)
const posSub = ball1.position.subtract(ball2.position);
//内积
const dotProduct = ball1.velocity.subtract(ball2.velocity).dotProduct(posSub);
//向量长度的平方
const lengthSquare = (ball1.position.x - ball2.position.x) ** 2 + (ball1.position.y - ball2.position.y) ** 2;
//质量比部分
const weightRatio = 2 * ball2.weight / (ball1.weight + ball2.weight);
return ball1.velocity.subtract(posSub.multiply(weightRatio * dotProduct / lengthSquare));
}
接下来就是要在球的update方法中添加碰撞。检测两个球是否碰撞,如果碰撞就变色。将变色部分替换成碰撞公式。这里有一个潜在的问题:两个球碰撞都是针对于碰撞发生瞬间前的运动状态进行处理的, 因此不能单个更新球的运动状态, 因为当开始更新另一个碰撞的球时, 原先那个球的运动状态已经发生了变化, 导致原先的运动状态发生了丢失。所以这里在发生碰撞时, 一同时更新碰撞的两个球的运动状态,然后将他们缓存起来, 每当更新球的运动状态时进行判断, 如果已经更新过球的运动状态, 就不再更新
class Ball implements Balls {
/*...... */
collision: Balls[] = [];
/**
*
* @param ctrl 控制器
* @param timer 时间
* @param updateId 更新ID
*/
update(ctrl: Controllers, timer: number, updateId: number) {
//球位置碰触到画布左右边界
if (this.position.x + this.r >= ctrl.canvas.$canvas.width || this.position.x - this.r <= 0) {
this.velocity = new Vector(-this.velocity.x, this.velocity.y)
}
//球的位置碰触到画布上下边界
if (this.position.y + this.r >= ctrl.canvas.$canvas.height || this.position.y - this.r <= 0) {
this.velocity = new Vector(this.velocity.x, -this.velocity.y);
}
//碰撞检测
ctrl.elms.forEach((item: Balls) => {
if (item === this) return;
//俩圆心之间的距离
const distance = Math.sqrt((this.position.x - item.position.x) ** 2 + (this.position.y - item.position.y) ** 2);
if (distance <= this.r + item.r) {
//如果已经被目标球记录过, 说明已经在发生碰撞时进行过计算了, 就直接跳过
if (item.collision.includes(this)) {
return ;
}
const b1Velocity = this.collisionChanged(this, item);
const b2Velocity = this.collisionChanged(item, this);
this.velocity = b1Velocity;
item.velocity = b2Velocity;
this.collision.push(this, item);
}
})
return Object.assign(this, {position: this.position.add(this.velocity)})
}
问题
两个球的部分重叠在一起,无法分开。运动轨迹也变得奇怪。分析方案, 发生重叠的情况大致下面几种:
-
- 随机生成球的时候发生了重叠。
-
- 当三个球位置很近, 其中一个球和另一个球发生碰撞, 位置更新后与另一个球发生了重叠。
-
- 墙体碰撞更新后位置与另一个球发生了重叠。 把小球的球心看作是一块强磁铁, 当两个小球发生重叠时, 让斥力发挥作用, 改变球体运动轨迹直到不发生重叠, 然后移除斥力作用。
球体发生重叠时, 无非就是某个时刻两个球的位置有重叠部分。斥力模型:在下一帧绘制之前, 让球体脱离重叠状态。所以让下一帧绘制的时候, 如果球体处于重叠态, 直接置为非重叠状态。
在每次碰撞计算后, 进行下一帧的位置计算。如果下一帧中, 发生碰撞的两个球处于重叠状态, 那么就以两个球的圆心连线为角度, 以两球半径之和减去实际圆心的距离作为需要调整的量,
按照角度的cos值和sin值分别计算脱离重叠状态总共需要移动的x偏移量和y偏移量, 然后以两球的质量比作为分配原则, 将x偏移量和y偏移量分配给两个球, 让他们在下一帧绘制时脱离重叠状态。
分配原则是让球移动最少的距离来摆脱重叠状态, 所以分别比较两个球的球心x和y, 让小的一个的x值减少, 大的x值增加, y同理。在这种方案下, 我们能够基本保证, 发生重叠的帧不超过一帧, 在连续的动画下将难以察觉。
update(ctrl: Controllers, timer: number, updateId: number, color) {
//球位置碰触到画布左右边界
if (this.position.x + this.r >= ctrl.canvas.$canvas.width || this.position.x - this.r <= 0) {
this.velocity = new Vector(-this.velocity.x, this.velocity.y)
}
//球的位置碰触到画布上下边界
if (this.position.y + this.r >= ctrl.canvas.$canvas.height || this.position.y - this.r <= 0) {
this.velocity = new Vector(this.velocity.x, -this.velocity.y);
}
//碰撞检测
ctrl.elms.forEach((item: Balls) => {
if (item === this) return;
//俩圆心之间的距离
const distance = Math.sqrt((this.position.x - item.position.x) ** 2 + (this.position.y - item.position.y) ** 2);
if (distance <= this.r + item.r) {
//如果已经被目标球记录过, 说明已经在发生碰撞时进行过计算了, 就直接跳过
if (color === "color") {
this.color = this.color === "#FF0000" ? "#00FF00" : "#FF0000";
item.color = item.color === "#FF0000" ? "#00FF00" : "#FF0000";
} else {
if (item.collision.includes(this)) {
return ;
}
const b1Velocity = this.collisionChanged(this, item);
const b2Velocity = this.collisionChanged(item, this);
this.velocity = b1Velocity;
item.velocity = b2Velocity;
/**
* 为了避免球体粘连, 当发生碰撞后, 下一帧的更新必须让两个球体分开
* 这里先计算下一帧时两个球体之间的距离, 如果已经处于分离状态
* 则不需要进行干预, 如果仍旧有重叠部分, 改变两个碰撞球的位置
* 改变的值为能够绘制时达到分离的值, 方向为两个圆心的夹角
*/
const nextPosA = this.position.add(this.velocity);
const nextPosB = item.position.add(item.velocity);
const nextDistance = Math.sqrt((nextPosA.x - nextPosB.x) ** 2 + (nextPosA.y - nextPosB.y) ** 2);
if(nextDistance < this.r + item.r) {
const deltaX = Math.abs(this.position.x - item.position.x);
const deltaY = Math.abs(this.position.y - item.position.y);
const degree = Math.atan2(deltaX, deltaY);
const deltaPosX = (this.r + item.r) * Math.cos(degree);
const deltaPosY = (this.r + item.r) * Math.sin(degree);
if (this.position.x <= item.position.x) {
this.position.x -= deltaX *(this.r / (this.r + item.r));
} else {
item.position.x -= deltaX *(item.r / (this.r + item.r));
}
if(this.position.y <= item.position.y) {
this.position.y -= deltaY *(this.r / (this.r + item.r));
} else {
item.position.y -= deltaY *(item.r / (this.r + item.r));
}
}
this.collision.push(this, item);
item.collision.push(item, this);
}
}
})
//计算最终球的位置时, 进行边界判断, 避免因为碰撞发生卡入墙体
const finalPosition = this.position.add(this.velocity);
finalPosition.x = Math.min(ctrl.canvas.$canvas.width - this.r, Math.max(0 + this.r, finalPosition.x));
finalPosition.y = Math.min(ctrl.canvas.$canvas.height - this.r, Math.max(0 + this.r, finalPosition.y));
return Object.assign(this, {position: finalPosition})
}