前言
最近看芜湖马直播打台球感觉很有趣,就萌生了使用 JavaScript
制作一款网页台球游戏的想法.
其实观察台球游戏规则玩法还挺简单的,1个母球、8个子球、6个球洞。用球杆打击母球,母球撞击子球 子球进入球洞得分,否则不得分。了解了台球的游戏思路,那么就要考虑如何使用代码去实现。
从这款游戏来看到一些重要的特性:
- 物体运动
- 物体的碰撞检测
JavaScript
有很多很优秀的物理相关的动画库,但我打算试试不用框架来编写一个简单的 物理引擎 来实现这款游戏的物理效果。
所以本章内容偏长,前半部分写的是如何制作简单的物理引擎,后半部分是台球游戏的实现。以笔记形式记录制作过程
了解 canvas
H5中的 Canvas 元素又称为“画布”,是 H5 的核心技术之一。常说的 Canvas 技术,指的就是使用 Canvas 元素结合 JavaScript 来绘制各种图形的技术。
<canvas id="myCanvas" width="300" height="300"> </canvas>
复制代码
// 一、获取到canvas元素
let canvas = document.getElementById("myCanvas");
// 二、执行上下文(绘制画笔)
let ctx = canvas.getContext('2d');
复制代码
Canvas 的其他基础 API 这里就不细说了,大致就是分为 画直线、曲线、变形、文本等等...
主要说说画球(圆形) :ctx.arc(x, y,半径,开始角度,结束角度,顺时针);
- 其中需要注意的是
js
中用到的角度单位
是 弧度 。 - 公式为:
弧度 = 角度 * Math.PI / 180; 带入公式: 360*Math.PI/180 => 2*Math.PI
对小球的方法进行封装,后续是基于这个进行的。
//ball.js
//球的 坐标 半径 颜色
function Ball(x, y, radius, color){
this.x = x || 0;
this.y = y || 0;
this.radius = radius || 10;
this.color = color || "blue";
}
//绘制"填充"小球
Ball.prototype.fill = function (ctx) {
ctx.save();
ctx.translate(this.x, this.y); //移动到坐标点
//画圆
ctx.beginPath();
ctx.arc(0, 0, this.radius, 0,2*Math.PI, false);
ctx.closePath();
// 填充颜色
ctx.fillStyle = this.color;
ctx.fill();
//恢复初始状态
ctx.restore();
}
//index
//使用
let ball = new Ball(20,20,20,'blue');
ball.fill(ctx);
复制代码
canvas 动画
Canvas动画实际上就是一个“不断清除、重绘、清除、重绘的过程”。
也就是说,想要实现Canvas动画,也就只有两步。
-
clearRect()
方法“清除” 整个Canvas。 -
绘制下一步
而编写动画循环的关键是要知道延迟时间多长合适?
- 显示器的刷新频率一般是60Hz (每秒刷新60次)。大多数浏览器都会对重绘操作加以限制,不超过显示器的重绘频率。
- 最平滑动画的最佳循环间隔是1000ms/60,约等于16.6ms
计时器 setInterval 实现动画的缺点
- 可能不精准!
定时器任务放到异步队列,只有当主线程任务执行完后才到它。可能会引起丢帧卡顿的情况
列如:主线程发送一个命令执行动画时,浏览器开始刷第一帧 过一会儿定时器才开始执行执行动画,此时浏览器正在处于第一帧与第二帧的中间,定时器才开始刷一帧。...
神器 requestAnimationFrame() 方法实现
- 原理:主线程上完成,根据浏览器重绘频率来触发自身! 保持最佳绘制效率
//让球动起来!
let vx = 2;
// -- 循环 --
(function from(){
requestAnimationFrame(from);
ctx.clearRect(0,0,canvas.width,canvas.height);//清除画布
ball.x += vx; //坐标往右移动 2
ball.fill(ctx); //绘制球
})()
复制代码
执行过程:擦掉画本,移动,画画。擦掉画本,移动,画画。擦掉画本,移动,画画 。
gif看起来卡顿,是录制问题,实际上是非常流畅的。
物理运动
加速运动
前一个例子球体运动可以叫做 匀速运动。
而加速运动则是 球体以一个初始速度为基础,速度越来越快。最后超越光速,浏览器爆炸。
let vx = 0.05; //初始速度
let ax = 0.5; //每次的加速度
//---循环--- 省略
ball.x += vx;
ball.fill(ctx);//绘制
vx += ax; //初始速度每次叠加 加速度
复制代码
加速度其实很好理解,每次循环都往初始速度叠加一个数,球的速度就会越来越快
重力加速度
重力加速度的公式和加速运动的公式是一样的。 这里以y
坐标举例
ball.y += vy;
vy += gravity;
复制代码
举例:利用重力让小球形成抛物线
let ball = new Ball(0,canvas.height);
ball.fill(ctx);
let vx = 3.5;
let vy = -8;
let gravity = 0.2;//重力加速度
// --循环--
ball.x+=vx;
ball.y+=vy;
ball.fill(ctx);
vy += gravity;
复制代码
执行过程:
注意: js的坐标系和数学中的坐标系是相反的!Y坐标向上是负的
-
球定义在画布左下角
-
球的X坐标向右做匀速运动
-
Y做向上运动,然后它的速度叠加重力。
vy
数据变化过程-8 -7.8 -7.6 ...
达到最高点时为 0 再然后vy
的值逐渐变为正数0.2 0.4 0.6 ...
球体做向下运动。
边界检测
边界检测,指的就是检测一个物体的活动范围。如上的例子中,我们没有对球进行活动范围的限制,导致它移出画布之外。
- 语法
// 边界检测
if(ball.x < ball.radius){
// 碰到左边
}else if(ball.x >= canvas.width-ball.radius){
// 碰到右边
}
if(ball.y < ball.radius){
// 碰到上边
}else if(ball.y >= canvas.height-ball.radius){
// 碰到下边
}
复制代码
以上语法是检测一个球,是否碰到画布的各条边。 图如下:
小球中心与画布边界的距离刚好是小球的半径。
结合边界检测和重力,再来实现一个球自由下落 触底反弹的例子
一般情况下,小球碰到地面都会反弹,由于反弹会有速度损耗,并且小球 y轴
速度方向会变为反方向,因此需要乘以一个反弹系数bounce
。反弹系数取值一般为-1.0~0之间的任意数。
反弹系数取值 -1 ~ 0 的原因 :既然是反弹,那么肯定是要向反方向出发的,所以是负数,**-1 **为原速度向反方向走(无损耗)。0则不反弹。
let vx = 2;
let vy = 2;
let gravity = 0.4; //重力
let bounce = -0.75;//反弹系数
// --循环--
ball.x+=vx;
ball.y+=vy;
// 边界检测
if(ball.x < ball.radius || ball.x >= canvas.width-ball.radius){
// 碰到左边或者右边
vx *= -1; //往反方向走
}
if(ball.y >= canvas.height-ball.radius){
// 碰到下边
ball.y = canvas.height-ball.radius;
vy *= bounce;
}
ball.fill(ctx);
vy += gravity;
复制代码
例子流程分解:
以上例子中定义了一个重力 0.4 和一个反弹系数 -0.75; 重力的作用是为了让球下降速度递增,而当球碰到地面的时候它的速度 vy
向反方向(也就是向上) 走自身移动距离的 75%
。
可以模拟vy
的数据走向:
-
球向下移动 2.4、2.8、3.2、3.6、4.0 ....
-
假设
vy
碰到地面时的数据是 10 ,那么这时候它就应该是10*-0.75 = -7.5 球是往上运动的 反弹时速度是最快的,越往上逐渐减少直到0。小球下落,再回弹... -
vy
第一次碰到地面的距离为10,那么第二次碰到地面的距离就会是 7.5 ,第三次5.625,为什么?因为vy
反弹时球会向上走它自身的0.75,那么当它走到顶端(0)时,下降的距离也会只有7.5。第二次反弹时为7.5*0.75 = 5.625
... 直到第N次vy
无限接近于0 ,小球停止。
摩擦力
摩擦力,指的是阻碍物体相对运动的力。方向与物体运动方向相反,摩擦力会改变速度大小而不会改变它的方向,只能将物体的速度降至0,无法让物体掉头。
let vx = 5;
let friction = 0.98;
// --循环--
ball.x+=vx;
ball.fill(ctx);
vx *= friction;
复制代码
摩擦力很简单,当前速度等于上一个速度的0.98倍 .... 直到无限接近于0,小球停止
碰撞检测
两球相撞
之前的章节中我们判断的是球与边界之间的碰撞检测,这次来看两个球体之间的碰撞检测。
- 先说结论,判断两个圆相撞,求两个圆心的距离 如果等于它俩的半径总和,则视为相撞,否则不相撞
如图:
封装一个方法来判定两球是否相撞
function checkStrike(ballA,ballB){
let dx = ballB.x - ballA.x;
let dy = ballB.y - ballA.y;
let distance = Math.sqrt(dx*dx+dy*dy);
if(distance <= (ballA.radius+ballB.radius)){
return true;
}else{
return false;
}
}
复制代码
这个函数接收两个参数,分别是球A,球B。 首先求出圆心的距离 dx、dy
然后再利用勾股定理求出第三条边的距离。得到相隔距离后再判断它是否小于等于两球的半径相加,从而判定是否相撞。
- 使用:
let ballA = new Ball(20,100);
let ballB = new Ball(200,100);
let vx = 1;
//--循环--
ballA.x+=vx; //向右走
ballB.x+= -vx;//向左走
if(checkStrike(ballA,ballB)){ //判断是否相撞
vx *= -1;//速度取相反数,原先向右走的向左走 , 另一个反之
}
ballA.fill(ctx);//渲染
ballB.fill(ctx);
复制代码
球的斜碰
这是本文最重要也是比较难顶的一个点了,两个小球不在一条水平线运动,并发生碰撞时,他们分别该如何走位?
先来了解一下向量的一些操作:
向量
向量:指具有大小(长度)和方向的量
程序中表示如 : <4,3> 。 (用 <> 表示向量) (起点为原点)
。本文需要用到对向量的一些计算公式
向量加法
把两个向量的 x
坐标和 y
坐标相加 : <4,3> + <1,2> = <5,5>
减法则类似
向量乘法
指的是向量和标量的乘法,标量就是普通的数字 。x和y分别和它相乘 : <4,3> * 3 = <12,9>
向量点乘
指的是一个向量在另一个向量上的投影大小。它的计算方式为两个向量的 x 的积加上 y 的积,它返回的是一个标量,例如:<4,3> · <1,2> = 4*1 + 3*2 = 10
如何理解这个投影的意思?可以尝试从物理的角度去解释
左图:
假设有一个人水平方向拉动一个小车,用的力 F
,拉动的距离为 s
,那么这个人作用到小车上的所用功就为 F*s
右图:
此时的作用力 F
方向为斜向上,那么此时 相对于距离 s
有用的力为 F1
。这块就可以看作为F
在s
上的投影F1
。 而 F2
向上的力相对于 s
来说是无用的力 与重力什么的抵消掉了
向量标准化
标准化是除掉向量的长度,只剩下方向,这样的向量它的长度为 1,称为单位向量。
对一个向量标准化后 它的结果为:保持方向不变,将向量的长度变为1
- 计算
还是以 <4,3>
向量为例子,需要将 **向量的各个分量 **都除以 该向量的 大小(长度) 。 来实现 向量的标准化
//向量长度 ( 某个坐标点的长度 ) -- 勾股定理
let len = Math.sqrt(4*4 + 3*3);// 5
let x = 4/5; //0.8
let y = 3/5; //0.6
复制代码
标准后的向量 <0.8,0.6>
。 经过计算他们的长度为 1 Math.sqrt(0.8*0.8 + 0.6*0.6); // 1
那么向量标准化的作用什么?
- 看一个例子:
假设玩家按下[左] [下]
按键时 人物往 坐标的移动一个距离 (1,1)。
现在希望 人物朝着**(1,1)** 这个方向 移动 [三个单位长度]
那么最后的坐标则为 (3,3)
真的是这样吗? - 把坐标带入勾股定理计算向量的长度
Math.sqrt(3*3 + 3*3);//4.242640687119285
复制代码
可以看出向量的长度是4点几,而并不是上面所说的3 ([三个单位长度]
)
先把向量转为单位向量,算出它的方向
let direction = Math.sqrt(1*1 + 1*1);//1.414
let x = y = 1/1.414; //约等于0.7
//再移动三个单位长度
let newX = x * 3; //2.1。
let newY = y * 3; //2.1。
//最后的坐标为 (2.1,2.1)
Math.sqrt(2.13*2.13+2.13*2.13); // 3 : 移动了三个单位长度
复制代码
- 封装向量工具类
function Vector(x,y){
this.x = x;
this.y = y;
}
Vector.prototype = {
add(v) {//加法
return new Vector(this.x + v.x, this.y + v.y);
},
multiply(s) {//和标量乘法
return new Vector(this.x * s, this.y * s);
},
dot(v) {//点乘
return this.x * v.x + this.y * v.y;
},
normalize() {//标准化
let distance = Math.sqrt(this.x * this.x + this.y * this.y);
return new Vector(this.x / distance, this.y / distance);
}
}
复制代码
计算碰撞后的速度时,遵守动量守恒定律和动能守恒定律,公式分别为:
- 动量守恒定律
- 动能守恒定律
m1、m2分别为小球质量(台球中球的质量都是一样的,即为1) v1、v2分别为速度向量 。 v1' v2'为碰撞后的速度向量。根据公式推导出碰撞后的速度公式:
碰撞的实现
在 Ball
类中加入 changeVelocityAndDirection(other)
函数,他接收另一个球作为参数。同时计算两个球的碰撞时的速度和方向。
创建两个碰撞小球此时的 速度向量
changeVelocityAndDirection(other) {
let velocity1 = new Vector(this.vx, this.vy);
let velocity2 = new Vector(other.vx, other.vy);
}
复制代码
取连心线方向的向量,也就是两个圆心坐标的差
let vNorm = new Vector(this.x - other.x, this.y - other.y);
复制代码
获取连心线方向的单位向量和切线方向的单位向量,切线的坐标其实就是把连心线向量的 x,y互换,y再取反
let unitVNorm = vNorm.normalize();
let unitVTan = new Vector(-unitVNorm.y, unitVNorm.x);
复制代码
这块求的是 速度向量分别在 连心线和且线上的投影的值。也就是速度向量分别在这两个方向(连心线和切线其实就可以看作X轴和Y轴)上占有多少 。 计算结果是一个标量,没有方向的速度值。
let v1n = velocity1.dot(unitVNorm);
let v1t = velocity1.dot(unitVTan);
let v2n = velocity2.dot(unitVNorm);
let v2t = velocity2.dot(unitVTan);
复制代码
求两个球相撞后的速度值,把速度
与质量
带入公式。由于台球的质量相同,那么 mass
记为 1 。结果为两个球的速度相互交换
let v1nAfter = (v1n * (this.mass - other.mass) + 2 * other.mass * v2n) / (this.mass + other.mass);
let v2nAfter = (v2n * (other.mass - this.mass) + 2 * this.mass * v1n) / (this.mass + other.mass);
//简化----------------------------------------
let v1nAfter = v2n;
let v2nAfter = v1n;
复制代码
再给碰撞后的速度加上方向,计算在连心线方向和切线方向上的速度
let v1VectorNorm = unitVNorm.multiply(v1nAfter);
let v1VectorTan = unitVTan.multiply(v1t);
let v2VectorNorm = unitVNorm.multiply(v2nAfter);
let v2VectorTan = unitVTan.multiply(v2t);
复制代码
有了两个小球连心线上的新速度向量和切线方向上的新速度向量,最后把连心线上的速度向量和切线方向的速度向量进行加法操作,就能获得碰撞后小球的速度向量:
let velocity1After = v1VectorNorm.add(v1VectorTan);
let velocity2After = v2VectorNorm.add(v2VectorTan);
复制代码
最后
this.vx = velocity1After.x;
this.vy = velocity1After.y;
other.vx = velocity2After.x;
other.vy = velocity2After.y;
复制代码
更详细可参考来源 -- 《JavaScript 游戏开发:手把手实现碰撞物理引擎》[峰华前端工程师]
球的碰撞检测到这部分就告一段落了,接下来就是如何使用以上这些方法实现一个台球游戏。
台球游戏制作
结构规划
我们需要两个 canvas
结构
- scene 用于布置场景:分别为台球桌子和台球。 台球桌背景可以用一张
css
背景图来搞定 - cover 用于绘制台球的辅助线
<canvas class="scene" width="900" height="600"></canvas>
<canvas class="cover" width="900" height="600"></canvas>
复制代码
const scene = document.querySelector('.scene');
const cover = document.querySelector('.cover');
复制代码
桌子类的创建
class Desk{
constructor() {}
init({scene,cover}){
this.balls = []; //球数组
this.F = 100; //力大小
this.isDown = false; //鼠标按下
this.scene = scene;
this.sceneCtx = scene.getContext('2d');
this.cover = cover;
this.coverCtx = cover.getContext('2d');
}
}
复制代码
主要是储存一些初始化的参数,balls 储存桌面所有球类的数组 , 分别储存 canvas 元素和它对应的上下文对象
球类的创建
let id = 0;
class Ball {
constructor() {
this.id = id; //每个球的id值
id++;
}
init({ w, h, r, x, y, vx = 0, vy = 0,color='#ffffff',type = ''}) {
this.w = w;
this.h = h;
this.r = r;
this.x = x;
this.y = y;
this.vx = vx;
this.vy = vy;
this.color = color;
this.type = type;
return this; //返回当前对象
}
}
复制代码
初始化一些球参数,分别是 宽 高 半径 位置 速度 颜色 类型(区分白球和其他球)
球的绘制
在 Desk
类中加入 initBall
方法,用于绘制所有的球。
//js中定义一些常量
const BALL_WIDTH = 28; //球宽高
const BALL_HEIGHT = 28;//球高度
const BALL_RADIUS = 14;//半径
const BALL_COLOR = ['#e6b746','#3370b1',...];//等等10种颜色
//Desk
// 绘制球
initBall(){
// 创建母球
const ball = new Ball();
let ballsArr = [ball.init({ w: BALL_WIDTH, h: BALL_HEIGHT,r:BALL_RADIUS, x: 200, y: this.scene.height / 2, vx: 0, vy: 0,color:"#ffffff", type: 'white' })];
let num = 0;
// 创建子球
for (let col = 0; col < 4; col++) {//4列
for (let i = 0; i < col + 1; i++) {//每列的球数量
const ball = new Ball();
const balls = ball.init({
w: BALL_WIDTH,
h: BALL_HEIGHT,
r:BALL_RADIUS,
x: 600 + col * BALL_WIDTH,
y: this.scene.height / 2 + i * BALL_HEIGHT - col * BALL_HEIGHT / 2,
vx: 0,
vy: 0,
color: BALL_COLOR[num]
});
ballsArr.push(balls);
num+=1;
}
}
this.balls = ballsArr;
this.run(true);
}
复制代码
这个函数的流程分别是
- 创建一个母球,位于桌子的中间靠左位置
- 创建子球,4列 每一列从一个球开始递增,一共10个球,位于桌子中间靠右的位置
- 执行自身(
Desk
)的run()
方法进行绘制
//Desk
run(isInit = false){
//清空画布
this.sceneCtx.clearRect(0, 0, this.scene.width, this.scene.height);
// 绘制球
for (let i = 0; i < this.balls.length; i++) {
console.log(1);
const ball = this.balls[i];
ball.addColor(this.sceneCtx); //执行球类自身的添加颜色函数
}
if (isInit) return;
requestAnimationFrame(() => this.run());
}
复制代码
球类加入一个addColor
方法绘制颜色
//Ball
addColor(ctx) {
ctx.save();
ctx.beginPath();
ctx.arc(this.x, this.y,this.r, 0, 2*Math.PI);//画圆
ctx.closePath();
ctx.strokeStyle = "#000000"; //黑色描边
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = this.color; //填充颜色
ctx.fill();
ctx.restore();
}
复制代码
母球的运动轨迹
母球的辅助线绘制
//Desk
init(){
...
// 鼠标移动事件
this.cover.addEventListener('mousemove', e => {
const x = e.layerX; //鼠标位置
const y = e.layerY;
const whiteBallX = this.balls[0].x; //白球位置
const whiteBallY = this.balls[0].y;
//白球瞄准线
this.coverCtx.clearRect(0, 0, this.scene.width, this.scene.height);
this.coverCtx.beginPath();
this.coverCtx.moveTo(whiteBallX, whiteBallY);
this.coverCtx.lineTo(x, y);
this.coverCtx.lineWidth = 1.5;
this.coverCtx.strokeStyle = '#0ff';
this.coverCtx.stroke();
// 瞄准线终点的模拟圆
this.coverCtx.beginPath();
this.coverCtx.arc(x , y, BALL_HEIGHT / 2, 0, 2 * Math.PI);
this.coverCtx.stroke();
});
}
复制代码
在 Desk
类 init()
方法中加入鼠标移动事件, 以 白球为起点 画一条连接鼠标的辅助线 ,每次移动就进行重绘
母球的运动
运动轨迹很简单,就是让母球往鼠标的方向以多大的力进行移动。力的大小以按下鼠标时设置大小,然后逐渐减小,以抬起鼠标时的力为准。也就是按得越久,力越小
//Desk
updateF(f) {
this.F = f < 1 ? 1 : f % 100;
$process.innerHTML = this.F; //html 中显示力大小的元素
}
复制代码
//Desk
init(){
...
//按下
this.cover.addEventListener('mousedown', e => {
this.isDown = true;
const addF = () => {
if (!this.isDown){return};
this.updateF(this.F - 1);
requestAnimationFrame(addF);
};
addF();
});
this.cover.addEventListener('mouseup', e => {
this.isDown = false;
const x = e.layerX;
const y = e.layerY;
const F = this.F / 3;//限制速度太快
const whiteBall = this.balls[0];
const vecX = x - whiteBall.x;
const vecY = y - whiteBall.y;
const l = Math.sqrt(vecX ** 2 + vecY ** 2); //鼠标与白球的距离
whiteBall.vx = vecX / l * F; //找到对应角度并往那移动
whiteBall.vy = vecY / l * F;
this.coverCtx.clearRect(0, 0, this.scene.width, this.scene.height);
this.run();
});
}
复制代码
this.isDown
作为判断按下抬起的阀值,抬起时 addF
循环退出 。
鼠标抬起时 设置白球的速度,并且触发Desk
类的循环函数 run
现在 力有了,球的速度有了。那么就让球动起来呗。
//Ball
ballRun() {
this.x += this.vx;
this.y += this.vy;
this.vx *= 0.99;//摩擦力
this.vy *= 0.99;
this.vx = Math.abs(this.vx) <= 0.1 ? 0 : this.vx; //小于 0.1 就停止运动 否则正常运动
this.vy = Math.abs(this.vy) <= 0.1 ? 0 : this.vy;
}
复制代码
球类加入了一个 ballRun
方法,让球移动,但是每次移动都会发生摩擦 直到停止。
- 使用
//Desk
run(){
...
for (let i = 0; i < this.balls.length; i++) {
...
ball.ballRun(); //新增!!!!!!!
}
}
复制代码
多球的碰撞检测
我们之前写的碰撞算法是两个球间的碰撞,多个球的碰撞的思路也是一致的。
如果有5个球,分别是 b1/b2/b3/b4/b5
那么他们的碰撞规则如下:
b1 -> b2/b3/b4/b5
b2 -> b3/b4/b5
b3 -> b4/b5
b4 -> b5
for (let i = 0; i < length; i++) {
for (let j = i + 1; j < length; j++) {
//碰撞检测
}
}
复制代码
开始写多球的碰撞代码: 在 Desk
类中加入一个碰撞检测的方法
//Desk
checkCollision() {
let len = this.balls.length;
for (let i = 0; i < len; i++) {
for (let j = i + 1; j < len; j++) {
this.balls[i].checkCollideWith(this.balls[j]);
}
}
}
复制代码
//Ball - 注意这些方法是球类的
checkCollideWith(other) {
if (this.isCircleCollided(other)) { //判断两个球是否相撞
this.changeVelocityAndDirection(other); // 碰撞后
}
}
//接收另一个小球对象作为参数,返回比较结果
isCircleCollided(other) {
let dx = this.x - other.x;
let dy = this.y - other.y;
let distance = Math.sqrt(dx*dx+dy*dy);
if(distance <= (this.r + other.r)){
return true;
}else{
return false;
}
}
复制代码
判断两球是否相撞,前面已经介绍过了。不再赘述
changeVelocityAndDirection()
方法就是碰撞检测写过的,一模一样 让它作为 Desk
类 的方法即可。
//Desk
run(){
...
this.checkCollision(); //执行碰撞检测
}
复制代码
边界检测
这里的边界检测并不以canvas宽高为边界的,而是上下左右都预留 50 左右的缝隙用于放球洞。
- 思路就是每次循环都检测所有球是否碰到边界
// Desk 碰壁方法
checkEdgeCollision(){
this.balls.forEach(ball=>{
if (this.isLeftRightBorder(ball)) { //左右边缘
ball.vx = -ball.vx;
}
if (this.isTopBottomBorder(ball)) {//上下边缘
ball.vy = -ball.vy;
}
});
}
复制代码
// 左右边缘
isLeftRightBorder(ball) {
const x = ball.x + ball.vx;
const y = ball.y + ball.vy;
const width = this.scene.width;//canvas宽高
const height = this.scene.height;
const diagonalCaveWidth = 50;//边界预留
return (
(x <= diagonalCaveWidth && (y >= diagonalCaveWidth || y <= height - diagonalCaveWidth)) ||
(x >= width - diagonalCaveWidth && (y >= diagonalCaveWidth || y <= height - diagonalCaveWidth))
);
}
// 上下边缘
isTopBottomBorder(ball) {
//和isLeftRightBorder方法类似的变量,省略...
return (
(y <= diagonalCaveWidth && (x >= diagonalCaveWidth || x <= width - diagonalCaveWidth)) ||
(y >= height - diagonalCaveWidth && (x >= diagonalCaveWidth || x <= width - diagonalCaveWidth))
);
}
复制代码
其实判定很简单,以左右边缘为例子
当球x
小于左边预留值,并且y
在框内 。说明碰到左边边框 此时返回 true
。 当大于右边预留值并且y
在框内也是返回true
其他情况说明没碰到左右边框。
- 使用
//Desk
run(){
...
this.checkEdgeCollision();
}
复制代码
球洞的制作
台球的球洞为6个,分别为左上左下,中上中下,右上右下
写一个方法判断球进洞的 isGoal
//Desk
//检测进球
isGoal(ball) {
const y = ball.y + ball.vy;
const x = ball.x + ball.vx;
const width = this.scene.width;
const height = this.scene.height;
const diagonalCaveWidth = 60;
const centerCaveWidth = 50;
return (
(y <= diagonalCaveWidth && x <= diagonalCaveWidth) ||
(y >= height - diagonalCaveWidth && x <= diagonalCaveWidth) ||
(y <= centerCaveWidth && x <= width / 2 + centerCaveWidth / 2 && x >= width / 2 - centerCaveWidth / 2) ||
(y >= height - centerCaveWidth && x <= width / 2 + centerCaveWidth / 2 && x >= width / 2 - centerCaveWidth / 2) ||
(y <= diagonalCaveWidth && x >= width - diagonalCaveWidth) ||
(y >= height - diagonalCaveWidth && x >= width - diagonalCaveWidth)
);
}
复制代码
详细看 return
处。
第一个括号表示检测到球在左上角的位置,y
在上边的位置 x
在左边位置 。值为 true
,说明在球在左上
第二个为 左下
第三个为 中上
...
- 使用
//Desk
checkEdgeCollision(){
this.balls.forEach(ball=>{
//1.进球
if(this.isGoal(ball)){
// 删掉数组中的球
for (let i = 0; i < this.balls.length; i++) {
if (this.balls[i].id === ball.id) {
this.balls.splice(i, 1);
return;
}
}
}
//2.碰壁
//...
}
}
复制代码
最终版
最后把球桌替换成一张台球的背景图,把辅助观看的线条去掉。再把一些参数进行调整,如这个版本的球桌的宽高分别为:1062*600
。调整球洞的位置以及大小后。得到最终的效果如下:
最后
当然这个版本的台球游戏还有很多不足和细节没有进行处理,如 子球进洞计算分数、母球进洞如何处理、子球运动过程为纯色的(现实中的子球是有数字的,而且滚动还会旋转)、母球击球的角度等等...
通过这个项目晓得了在游戏开发中 学好数学是有多重要了...
希望这篇文章对你有所帮助。