使用 JavaScript 从 0 到 1 制作一款台球游戏

前言

最近看芜湖马直播打台球感觉很有趣,就萌生了使用 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);
复制代码

1.JPG

canvas 动画

Canvas动画实际上就是一个“不断清除、重绘、清除、重绘的过程”。

也就是说,想要实现Canvas动画,也就只有两步。

  • clearRect()方法“清除” 整个Canvas。

  • 绘制下一步

而编写动画循环的关键是要知道延迟时间多长合适?

  1. 显示器的刷新频率一般是60Hz (每秒刷新60次)。大多数浏览器都会对重绘操作加以限制,不超过显示器的重绘频率。
  2. 最平滑动画的最佳循环间隔是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看起来卡顿,是录制问题,实际上是非常流畅的。

1.gif

物理运动

加速运动

前一个例子球体运动可以叫做 匀速运动。

加速运动则是 球体以一个初始速度为基础,速度越来越快。最后超越光速,浏览器爆炸。

let vx = 0.05; //初始速度
let ax = 0.5; //每次的加速度

//---循环--- 省略
ball.x += vx;
ball.fill(ctx);//绘制
vx += ax; //初始速度每次叠加 加速度
复制代码

2.gif

加速度其实很好理解,每次循环都往初始速度叠加一个数,球的速度就会越来越快

重力加速度

重力加速度的公式和加速运动的公式是一样的。 这里以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;
复制代码

3.gif

执行过程:

注意: js的坐标系和数学中的坐标系是相反的!Y坐标向上是负的

  1. 球定义在画布左下角

  2. 球的X坐标向右做匀速运动

  3. 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){
    // 碰到下边
}
复制代码

以上语法是检测一个球,是否碰到画布的各条边。 图如下:

边界检测.png

小球中心与画布边界的距离刚好是小球的半径。

结合边界检测和重力,再来实现一个球自由下落 触底反弹的例子

一般情况下,小球碰到地面都会反弹,由于反弹会有速度损耗,并且小球 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;
复制代码

4.gif

例子流程分解:

以上例子中定义了一个重力 0.4 和一个反弹系数 -0.75; 重力的作用是为了让球下降速度递增,而当球碰到地面的时候它的速度 vy 向反方向(也就是向上) 走自身移动距离的 75%

可以模拟vy的数据走向:

  1. 球向下移动 2.4、2.8、3.2、3.6、4.0 ....

  2. 假设 vy 碰到地面时的数据是 10 ,那么这时候它就应该是10*-0.75 = -7.5 球是往上运动的 反弹时速度是最快的,越往上逐渐减少直到0。小球下落,再回弹...

  3. 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;
复制代码

5.gif

摩擦力很简单,当前速度等于上一个速度的0.98倍 .... 直到无限接近于0,小球停止

碰撞检测

两球相撞

之前的章节中我们判断的是球与边界之间的碰撞检测,这次来看两个球体之间的碰撞检测。

  • 先说结论,判断两个圆相撞,求两个圆心的距离 如果等于它俩的半径总和,则视为相撞,否则不相撞

如图:

两圆相撞.png

封装一个方法来判定两球是否相撞

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);
复制代码

6.gif

球的斜碰

这是本文最重要也是比较难顶的一个点了,两个小球不在一条水平线运动,并发生碰撞时,他们分别该如何走位?

先来了解一下向量的一些操作:

向量

向量:指具有大小(长度)方向的量

程序中表示如 : <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

向量投影.png

如何理解这个投影的意思?可以尝试从物理的角度去解释

物理方式-向量投影2.png

左图:

假设有一个人水平方向拉动一个小车,用的力 F,拉动的距离为 s ,那么这个人作用到小车上的所用功就为 F*s

右图:

此时的作用力 F 方向为斜向上,那么此时 相对于距离 s 有用的力F1 。这块就可以看作为Fs上的投影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 : 移动了三个单位长度
复制代码

单位向量.png

  • 封装向量工具类
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);
    }
}
复制代码

计算碰撞后的速度时,遵守动量守恒定律和动能守恒定律,公式分别为:

  • 动量守恒定律

动量守恒.JPG

  • 动能守恒定律

动能守恒定律.JPG

m1、m2分别为小球质量(台球中球的质量都是一样的,即为1) v1、v2分别为速度向量 。 v1' v2'为碰撞后的速度向量。根据公式推导出碰撞后的速度公式:

推导.JPG

碰撞的实现

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;
复制代码

斜碰.png

7.gif

更详细可参考来源 -- 《JavaScript 游戏开发:手把手实现碰撞物理引擎》[峰华前端工程师]

球的碰撞检测到这部分就告一段落了,接下来就是如何使用以上这些方法实现一个台球游戏。

台球游戏制作

结构规划

我们需要两个 canvas 结构

  1. scene 用于布置场景:分别为台球桌子和台球。 台球桌背景可以用一张css背景图来搞定
  2. 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);
}
复制代码

这个函数的流程分别是

  1. 创建一个母球,位于桌子的中间靠左位置
  2. 创建子球,4列 每一列从一个球开始递增,一共10个球,位于桌子中间靠右的位置
  3. 执行自身(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();
}
复制代码

桌面球.JPG

母球的运动轨迹

母球的辅助线绘制

//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();
    });
    
}
复制代码

Deskinit()方法中加入鼠标移动事件, 以 白球为起点 画一条连接鼠标的辅助线 ,每次移动就进行重绘

8.gif

母球的运动

运动轨迹很简单,就是让母球往鼠标的方向以多大的力进行移动。力的大小以按下鼠标时设置大小,然后逐渐减小,以抬起鼠标时的力为准。也就是按得越久,力越小

//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(); //新增!!!!!!!
    }
}
复制代码

9.gif

多球的碰撞检测

我们之前写的碰撞算法是两个球间的碰撞,多个球的碰撞的思路也是一致的。

如果有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(); //执行碰撞检测
}
复制代码

10.gif

边界检测

这里的边界检测并不以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();
}
复制代码

11.gif

球洞的制作

台球的球洞为6个,分别为左上左下,中上中下,右上右下

球洞.JPG

写一个方法判断球进洞的 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.碰壁
        //...
    }
}
复制代码

12.gif

最终版

最后把球桌替换成一张台球的背景图,把辅助观看的线条去掉。再把一些参数进行调整,如这个版本的球桌的宽高分别为:1062*600 。调整球洞的位置以及大小后。得到最终的效果如下:

13.gif

最后

当然这个版本的台球游戏还有很多不足和细节没有进行处理,如 子球进洞计算分数、母球进洞如何处理、子球运动过程为纯色的(现实中的子球是有数字的,而且滚动还会旋转)、母球击球的角度等等...

通过这个项目晓得了在游戏开发中 学好数学是有多重要了...

希望这篇文章对你有所帮助。

猜你喜欢

转载自juejin.im/post/7038405121692139533