由「投壶」游戏延伸到光线投射检测丨PixiJs

1,752 阅读7分钟

「本文已参与好文召集令活动,点击查看: 后端、大前端双赛道投稿,2万元奖池等你挑战!

前言

在游戏开发中, 我们经常会用到很多的「碰撞检测」,比较复杂的场景我们可能会用到一些碰撞检测库. 比如matterjsp2等物理引擎.
碰撞检测的方法有很多, 具体场景具体分析, 比较好用的就是SAT定理、射线检测等.
接下来我们从一个简单的小游戏「投壶」出发, 介绍下「边界检测」和「光线投射检测」是如何实现的.

投壶游戏是什么?

投壶源自于射礼, 是一种将箭矢投进投壶的投掷类游戏, 它既是一种礼仪, 也是一种游戏. “投壶可以治心,可以修身,可以为国,可以观人”

游戏规则:

长按鼠标, 调整角度, 将箭矢准确投入到投壶中

我们先来看下游戏怎么玩:

test.gif

接下来, 我们正式介绍如何开发这个小游戏.

一、环境搭建

这里采用pixiJs作为游戏引擎.
首先, 我们先来简单搭建一个环境,使用webpack搭建一个TS开发环境.

const path = require('path');
module.exports = {
    entry: './src/main.ts',
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                use: 'ts-loader', 
                exclude: /node_modules/
            }
        ]
    },
    resolve: {
        extensions: ['.tsx', '.ts', '.js']
    },
    output: {
        filename: 'output.js',
        path: __dirname,
        libraryTarget: 'umd',
    }
};

接下来我们引入pixijs引擎:

//pixi
yarn add --save pixi.js
//安装typings
yarn add -g typings
//生成代码提示 .d.ts
typings install --global --save-dev dt~pixi.js

这样子一个简单的开发环境就搭好了. 我们来书写一下入口文件Main.ts.

import { IndexScene } from "./core/IndexScene";
export class Main {
    /** 首页 */
    private indexScene: IndexScene;
    constructor(canvas: HTMLCanvasElement) {
        const width = document.body.clientWidth * (window.devicePixelRatio || 1);
        const height = document.body.clientHeight * (window.devicePixelRatio || 1);
        const app = new PIXI.Application(width, height, {
            view: canvas,
            backgroundColor: 0x1099bb
        });
        this.indexScene = app.stage.addChild(new IndexScene(width, height));
    }
}

IndexScene是我们的主场景, 「所写即所见」.
这里的环境搭建就不多展开了, 我们开始今天的主要内容.

二、游戏实现

1、游戏界面

首先, 搭建游戏界面, 一只箭失, 一个投壶, 功能面板(速度、角度、蓄力展示).

// 桶底层
this.barrel = this.addChild(Sprite.from("../../assets/barrel.png"));
..
//箭
this.arrow = this.addChild(new Arrow());
...
//桶表层
this.barrel = this.addChild(Sprite.from("../../assets/barrelMask.png"));
...
//文本
let style = {
    fontFamily: "Arial",
    fontSize: 66,
    fill: "white",
    stroke: '#f64066',
    strokeThickness: 4,
};
//速度展示文本
const speedTxt = this.addChild(new Text("速度: 0", style));
...

//初始角度显示
const rotationTxt = this.addChild(new Text("初始角度: 0", style));
...

这样子就得到如下的游戏场景, 场景的搭建非常简单, 就不赘述了.

2、让箭失运动起来

我们思考, 如何开发这个投壶玩法:

  • 箭矢跟随鼠标转动
  • 箭矢以不同的力度射出
  • 箭矢做抛物线运动
  • 箭矢射进投壶
  • 箭矢掉出场地

首先, 我们需要让箭矢跟随我们的鼠标转动, 使用pixi中事件监听鼠标事件, 计算出箭矢角度.
通过Math.atan2(dy.dx)计算出从箭矢位置到鼠标位置的线段与x轴正方向之间的角度(弧度).
这样我们的箭矢就可以随着鼠标转动了.

this.on(MOUSE_EVENT.onMouseMove, this.onMouseMove, this);
//鼠标移动
private onMouseMove(e){
    const dx = e.data.global.x - this._width * 0.15;
    const dy = 800 - e.data.global.y;
    this.arrowRotation = -GUtils.getAngle(dy, dx);
    this.arrow.rotation = this.arrowRotation * Math.PI / 180;
}
...
// GUtils类 角度计算
static getAngle(dy: number, dx: number): number {
    const angle = Math.atan2(dy, dx) / Math.PI * 180;
    return angle;
}

接下来, 我们需要给箭矢一个弹射力.
我们上面已经计算出了箭矢的角度, 给定一个力force, 就可以得到x, y方向上力的分量, 这里的力指的是每段距离的位移增量. 通过鼠标长按实现增量this.force += 0.3 * rate

/** 蓄力 */
private force: number = 0;
/** 最大力 */
private maxForce: number = 15;
/** 蓄力 */
private addForce(rate: number = 1) {
    if (!this.isUp) return;
    this.force += 0.3 * rate;
    if (this.force > this.maxForce) {
        this.force = this.maxForce;
    }
    this.forceProgressMask.y = 594 - 394 * this.force / this.maxForce;
}

最后, 我们该怎么将箭矢弹射出去呢?

我们需要建立一个游戏循环, 即按一秒60帧, 每一帧16.7ms去绘制. 这里我们使用pixijsticker控制.

app.ticker.add(() => {
    this.indexScene.update(app.ticker.FPS);
});

执行游戏循环.

update(fps) {
    if (this.paused) return;
    let deltaTime: number = 0;
    const now = +new Date();
    if (this.lastTime) {
        deltaTime = now - this.lastTime;
    } else {
        deltaTime = 1000 / fps;
    }
    let rate: number = deltaTime / (1000 / fps);
    this.addForce(rate);
    this.updateArrowSpeed(rate);
    this.updateArrowPosition(rate);
    this.lastTime = now;
}

在游戏每一帧的更新中, 我们需要实时更新箭矢的受力、速度、位置和旋转角度.

那么应该怎么去计算呢?

每一帧我们都去给箭矢做增量, 同时竖直方向上受重力影响.
定义一个竖直方向上向下的重力.

/** 重力 */
const G: number = 0.2;

接下来计算箭矢实时位置.

//位移
this.arrow.x += horizontalVelocity * rate;
this.arrow.y -= verticalVelocity* rate;
//受重力
this.arrow.verticalVelocity -= G * rate;

下面是完整方法. rate是做帧率同步的系数. 可以避免失帧导致卡顿.

/** 更新箭位置 */
private updateArrowPosition(rate: number = 1) {
    if (this.isArrowMove) {
        const { horizontalVelocity, verticalVelocity } = this.arrow;
        this.arrowRotation = -GUtils.getAngle(verticalVelocity * rate, horizontalVelocity * rate);
        this.arrow.x += horizontalVelocity * rate;
        this.arrow.y -= verticalVelocity * rate;
        this.arrow.rotation = this.arrowRotation * Math.PI / 180;
    }
}

/** 更新箭的受力 */
private updateArrowSpeed(rate: number = 1) {
    if (this.isArrowMove) {
        this.arrow.verticalVelocity -= G * rate;
    }
}

做完上述步骤之后, 我们的箭矢已经可以弹射出去了, 并且可以平滑的做抛物线运动.
最后, 我们需要判断箭矢是否投入投壶中以及箭矢是否投出可投掷区域范围.

三、光线投射检测

1、数学基础

开始讲这个碰撞检测方法之前, 我们先来简单复习下数学知识.

如何确定一条直线?

平面上的直线是由平面直角坐标系中的一个二元一次方程所表示的图形.

这里我们只需要使用直线表达式中的斜截式.

//k是直线斜率 b 是y轴截距
y = kx + b

同时我们也知道, 当直线相对于x轴水平时, k为0; 当直线相对于x轴垂直时, k为∞.

如何判断两条直线相交呢?

我们现在有两条直线相交于一点(x, y);

  • 直线A y = k₁x + b₁

  • 直线B y = k₂x + b₂ 通过推理我们可以得到:

    k₁x + b₁ = k₂x + b₂

交换得到:

x = (b₂ - b₁) / (k₁ - k₂)

以上就是需要用到的数学知识了.

2、实践运用

我们拿这个公式有什么用呢?

箭矢在运动过程中我们可以拿到速度分量从而确定一条直线,
而我们的投壶的壶口可以看成是一条与x轴平行的直线,
当这两条直线相交的时候, 我们是不是可以认为箭矢投中了.
当然, 直线是无限长的, 我们在判断的时候, 还可以通过「边界检测」方法检测交点是否在投壶的范围之内.
这样子, 我们的碰撞检测是不是就更加精准了.

我们可以来看下图解:

ddd.png

当交点(x, y)处于x1, x2中间, 同时交点(x, y)处于桶的目标范围时, 箭矢可以投进.

用代码怎么表示呢?

这里我们会增加两个偏移量offsetWoffsetH, 因为桶的图片是有边缘偏移的, 所以我们需要稍微处理下偏移量, 使检测更加精准.

/* 检测是否进桶 */
public static isArrowEnterArea(arrow: Arrow, barrel: Sprite) {
    const offsetW = 5,offsetH = 10;//偏移
    const { x, y, width, verticalVelocity, horizontalVelocity, rotation } = arrow;
    const { x: _bx, y: _by, width: _bw, height: _bh } = barrel;
    let _x = x + width / 2 * Math.cos(rotation);
    let _y = y + width / 2 * Math.sin(rotation);
    let k1 = horizontalVelocity / verticalVelocity;
    let b1 = _y - k1 * _x;
    //计算交点 y
    let interSectionX = (_by - b1) / k1;
    return (
        interSectionX > _bx + offsetW &&
        interSectionX <= _bx + _bw - offsetW &&
        _x > _bx + offsetW &&
        _x <= _bx + _bw - offsetW &&
        _y > _by &&
        _y <= _by +  offsetH
    );
}

这样子我们就可以检测箭矢是否能够投进投壶中了.
而我们的箭矢不可能每次都投进, 还需要检测其是否超出边缘.
这个时候需要用到「边界检测」方法了.
判断箭头是否在矩形线框内.

/* 检测箭触及边框 */
public static isArrowOutBorder(arrow: Arrow, area: Graphics) {
    const { x, y, width, rotation } = arrow;
    const { x: _ox, y: _oy, width: _w, height: _h } = area;
    let _x = x + width / 2 * Math.cos(rotation);
    let _y = y + width / 2 * Math.sin(rotation);
    return (
        _x > _ox + _w ||
        _x < _ox ||
        _y > _oy + _h ||
        _y < _oy
    );
}

同理, 我们在游戏循环的时候, 加入碰撞检测, 判断游戏是否结束.

if (Collide.isArrowOutBorder(this.arrow, this.area)) {
    // alert("碰到边框")
    this.paused = true;
}

if (Collide.isArrowEnterArea(this.arrow, this.barrel)) {
    // alert("投进")
    this.paused = true;
    //投进复位
    this.enterBarrel();
}

到这里, 我们的「投壶」小游戏就做完了. 不同的业务场景需要不同的碰撞检测.

3、优缺点

「光线投射检测」有什么好处呢?

在运动较快的物体, 我们很难判断某个点处于目标的位置, 就很难精确判断, 而判断直线相交会更适用和精确.
我们可以运用到投篮、套圈等小游戏中.

「光线投射检测」有什么缺点呢?

适用范围小. 不规则图形检测也不给力

后记

本来是想写SAT定理的, 因为没有想到既简单又好玩的小例子, 所以就退而求其次, 先安排个「光线投射检测」.
其实游戏中的碰撞检测东西很多, 2d有凸多边形的分离轴检测(SAT)、像素检测等, 3D有射线检测(多用于射击游戏)等.

同样的, 投壶游戏仅仅只是这样吗

其实, 投壶还有俩壶耳, 很小的进口, 我们又要怎么去判断好呢?
更多的需要实践操作中去判断了.后续会在更新其他好玩的小游戏和游戏开发小知识.

文章粗浅, 望诸位不吝您的评论和点赞~
注: 本文系作者呕心沥血之作,转载须声明