原创不易,未经允许不得转载
阅读提示
- 以下内容涉及到线性代数,如果你已经忘记线代或者没有学过,可以先看看平面向量。
- 绘制使用的JS canvas 2d,以及配合requestAnimationFrame进行循环,不熟悉的朋友可以看我之前的文章。不过本文主要讲碰撞测试以及碰撞响应的方法,绘制其实可以忽略。
- 为了方便,代码是在微信小游戏上运行的。文章结尾有本文中的示例代码,是以共享小程序片段形式给出来的,所以要运行本文示例需要安装微信开发者工具。
质点和圆形
质点定义1:“质点就是有质量但不存在体积或形状的点,是物理学的一个理想化模型。在物体的大小和形状不起作用,或者所起的作用并不显著而可以忽略不计时,我们近似地把该物体看作是一个只具有质量而其体积、形状可以忽略不计的理想物体,用来代替物体的有质量的点称为质点”。既然不考虑其形状,那么就不需要考虑其转动,而只要关注它的线性运动即可。
想象一下,我们在绘制的时候,如果不考虑转动,那么我们可以将一个圆形看作一个质点。以下我们将会用一个一个的圆形来代表质点,并模拟他们的运动。
绘制一个移动的圆形
Canvas 2d的context提供了一个方法:arc(x,y,radius,startRadian,endRadian)
,专门用于绘制一段正圆形的弧,参数含义:
x
,y
表示的是正圆形的圆心坐标radius
表示半径startRadian
,endRadian
表示弧形开始的弧度以及结束的弧度
一个完整的圆形只要将起始弧度设置为0,终止弧度设置为2π即可。弧度和角度的换算如下:弧度=角度*180/π
根据这个方法,我们配合requestAnimationFrame
即可在canvas绘制出一个可以移动的圆形,这是game.js代码:
const PI = Math.PI;
let deltaX, deltaY;// 圆形移动坐标增量
deltaX = deltaY = 1;
// 圆形的圆心坐标以及半径
let x = 0;
let y = 0;
let radius = 30;
let ctx = wx.createCanvas().getContext('2d');
// 绘制一个圆
function drawCircle(ctx, x, y, radius) {
ctx.save();
ctx.strokeStyle = 'red';
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * PI);
ctx.closePath();
ctx.stroke();
ctx.restore();
}
// 循环绘制
function repeatDraw() {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
drawCircle(ctx, x, y, radius);
x += deltaX;
y += deltaY;
requestAnimationFrame(repeatDraw);
}
repeatDraw();
运行后会得到以下结果:
![](https://i-blog.csdnimg.cn/blog_migrate/a9f1e3aa0dbe8a06202beff1cc249979.gif)
速度,加速度和位置
我们一起温习一下中学物理的匀加速运动:
定义物体的速度为 V ( t i ) V(t_i) V(ti),在 t i + 1 t_{i+1} ti+1时间点,那么我们的速度为 V ( t i + 1 ) V(t_{i+1}) V(ti+1),如果加速度为 a a a,则:
d t = t i + 1 − t i V ( t i + 1 ) = V ( t i ) + a / d t dt = t_{i+1} - ti\\ V(t_{i+1}) = V(t_i) + a/dt dt=ti+1−tiV(ti+1)=V(ti)+a/dt
这里的 V V V是一个向量,应该写成 V ⃗ \vec V V,因为我们现在讨论的是二维,所以可以看成 V ⃗ : [ V x , V y ] \vec V: [V_x,V_y] V:[Vx,Vy],即 V ⃗ \vec V V由 V x , V y V_x,V_y Vx,Vy组成。
我们再定义物体的位置为: P ( t i ) P(t_i) P(ti),则 t i + 1 t_{i+1} ti+1时间点的物体位置 P ( t i + 1 ) P(t_{i+1}) P(ti+1):
P ( t i + 1 ) = P ( t i ) + V ( t i + 1 ) d t P(t_{i+1}) = P(t_i) + V(t_{i+1}) dt P(ti+1)=P(ti)+V(ti+1)dt
P P P由坐标 X , Y X,Y X,Y组成。
我们看看如何用代码套用上面的公式来计算速度、加速度以及位置:
圆形的位置是由其圆心决定的,所以圆心坐标(x,y)
就是公式里的 P P P。
我们在每次刷新到来之前都会重新计算圆心坐标,就像上面代码里写的:x += deltaX; y += deltaY;
,如果套用上面的公式,那实际上应该是这么写:
// currentVx表示当前时间的x方向的速度
// deltaTime表示从上个速度变化到这次速度的时间
x = x + currentVx*deltaTime;
y = y + currentVy*deltaTime;
因为我们在绘制过程中是按照刷新次数来代替时间的(刷新之间间隔大约16毫秒,我之前文章有讲到),每次刷新之间的间隔都为1,也就是说这个deltaTime
就是1
,所以上面的代码其实应该这么写:
// currentVx表示当前时间的x方向的速度
x = x + currentVx;
y = y + currentVy;
同理currentVx
和currentVy
的计算可以这么写:
// currentVx表示当前时间的x方向的速度
let ax = x轴的加速度
let ay = y轴的加速度
currentVx = currentVx + ax; // 因为deltaTime为1
currentVy = currentVy + ay;
现在我们新建一个类:Vector2.js,该类描述了一个二维向量,并实现了向量的加法和减法,以及和标量的乘法,还有向量之间的点乘和叉乘。
let _value = Symbol('二维向量值的数组,0位是x,1位是y');
export default class Vector2 {
constructor(x, y) {
this[_value] = new Float32Array(2);
this.x = x;
this.y = y;
}
.......
//这里是一些属性设置,比如x,y
.......
get magnitude() {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
static normalize(out, vector) {
let magnitude = vector.magnitude;
if (magnitude == 0) {
out.x = 0;
out.y = 0;
return out;
}
out.x = out.x / magnitude;
out.y = out.y / magnitude;
return out;
}
static add(out, v1, v2) {
out.x = v1.x + v2.x;
out.y = v1.y + v2.y;
}
static sub(out, v1, v2) {
out.x = v1.x - v2.x;
out.y = v1.y - v2.y;
}
static multiply(out, value) {
out.x *= value;
out.y *= value;
}
static div(out, value) {
this.multiply(out, 1 / value);
}
static dot(v1, v2) {
return v1.x * v2.x + v1.y * v2.y;
}
static cross(v1, v2) {
return v1.x * v2.y - v1.y * v2.x;
}
}
向量的叉乘实际上返回的应该一个向量而不是标量,可二维向量正好算出来只有一个值,所以这里的叉乘返回的只是一个标量而已。
现在我们就可以用这个类来表示速度以及加速度了,重新写之前的代码:
import Vector2 from "./example1208/Vector2";
const PI = Math.PI;
//实际位置算不上真正的向量,所以我们不用Vector2来表示它
let position = {
x:0,y:0};
let velocity = new Vector2(1,1);
let a = new Vector2(0,0);//我们加速度为0
let radius = 30;
let ctx = wx.createCanvas().getContext('2d');
// 绘制一个圆
function drawCircle(ctx, x, y, radius) {
ctx.save();
ctx.strokeStyle = 'red';
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * PI);
ctx.closePath();
ctx.stroke();
ctx.restore();
}
// 循环绘制
function repeatDraw() {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
drawCircle(ctx, position.x, position.y, radius);
Vector2.add(velocity,velocity,a);// 计算此刻的速度,相当于:v = v + a;
Vector2.add(position,position,velocity); //计算此刻的位置,相当于:x = x + vx;
requestAnimationFrame(repeatDraw);
}
repeatDraw();
我们还可以给加速度设置一些值,让这个圆形做变速运动。
如果给一个初速度 V = [ V x , 0 ] V = [V_x,0] V=[Vx,0],再模拟一个重力加速度 a = [ 0 , a y ] a = [0,a_y] a=[0,ay]即可模拟一个平抛运动。
碰撞判定
质点是没有大小的,所以这里我们如果讨论质点的碰撞判定是没意义的。但我们用正圆形来代替了质点,那就可以进行讨论了。
一个多边形,比如一个矩形,我们在碰撞判定的时候要考虑到它的4个顶点位置,同一个位置的同一个矩形,旋转角度不同判定结果也不同,有可能接触,也有可能没接触。而正圆形不管以什么角度、位置和其他的点、线接触其实都一样,我们只需要计算圆心和需要碰撞的参考物之间距离就可以判定是否接触。
正圆形碰撞其实也算到了多边形的碰撞范畴中,但我们常把其他多边形之间碰撞和圆形分开:圆形 vs 圆形,圆形 vs 多边形,多边形 vs 多边形。
其中多边形 vs 多边形的碰撞判定较为复杂,常用的有SAT和GJK判定算法,而这两个算法并不适用于圆形。
圆形和线段碰撞判定
我们学几何的时候知道:一个点到线的垂直距离最短。如果一个圆形的圆心到某条线的距离小于了圆形的半径,我们就可以认为这个圆形和这条线发生了接触。
可是如果是线段就要加上另外的判断条件了,因为线是无限长的,而线段是有限的,有可能圆到线的距离已经小于了半径,但是圆却在线段两端外,没有和线段接触。所以圆形和线段的碰撞分成两部分:
- 如果圆心到线段所在直线的投影坐标在线段外,检测圆心到线段端点的距离是否小于半径
- 如果圆心到线段所在直线的投影坐标在线段内,检测这段距离是否小于半径
计算出圆心到线段所在直线的投影点坐标是关键,我们注意到,计算圆心到直线的距离,其实就是计算圆心到投影点之间的距离,另外,我们还要计算圆心到端点距离,所以我们只需要做到:1. 计算出投影点的坐标位置;2. 实现一个方法能计算出点到点的距离。完成这两步就可以判断圆形是否和线段接触了。
投影点坐标的计算
通常我们可以根据直线斜率方程来计算出投影坐标,设线段所在直线方程为 y = k x + b y = kx+b y=kx+b,线外一点 p p p到该线段的投影点为 p