翻译:通过一个游戏学习 JavaScript

343 阅读6分钟

原文:Learn Javascript through a Game

如果你谷歌搜索 "Javascript" 方面,数以亿计的结果冒出来。这就是它的流行程度。几乎全部的 web 应用使用 Javascript。作为一个 JS 开发者,当遇到框架时有太多的选择,React,Node,Vue或者其他。在这个广阔的框架海洋中,我们常常变得忘记我们的好的老朋友,Vanilla JS,最纯粹的 Javascript 形式。

image.png

预备知识

这个项目没有预备知识只要你愿意学和做。但是,一点编程知识也不会坏,不是么?

项目

由于我们将覆盖项目的各个方面,此文章将是一篇长文章。因此,为了清晰和易于理解,整个项目被分成如下章节:

我们将要做的

image.png

在我们投入代码之前,我们需要规划究竟什么是我们将要制造的。我们需要制造一条蛇,它被头和尾代表,有很多片段组成。我们还需要在屏幕的随机位置产出某种食物,给蛇吃并且蛇会长长。我们将记录玩家的分数、还添加暂停游戏功能。

骨架

为此游戏创建一个独立的文件夹。在文件夹里创建两个文件,命名为 index.html 和 game.js。index.html 文件将包含常规 HMTL 样板代码并有一个很特别的元素,canvas,我们的游戏将苏醒的地方。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Snake Game</title>
</head>
<body>

    <canvas id="game-area"></canvas>
    <script type="text/javascript" src="game.js"></script>

</body>
</html>

HTML canvas 标签用于使用 Javascript 画图。它拥有内置的画简单图形比如弧形,矩形,直线的功能。它也能展示文字和图片。我们使用 script 标签添加对 game.js 文件的引用,该文件将规定游戏的逻辑。

在我们开始之前,我们需要在 HTML 文件的 head 标签内添加如下 style 标签:

<style type="text/css">
    *{
        margin: 0;
        padding: 0;
        overflow: hidden;
        box-sizing: border-box;
    }
    canvas{
        background-color: #333;
    }
</style>

为了覆盖浏览器元素上的默认设置,我们为页面写了一个自定义的 CSS 样式并设置 marginpadding 为0。border-box 属性把考虑添加到元素的边 (border) 并将其适合于元素内。overflow 属性被设置成 hidden 以禁止和隐藏元素滚动条。最后,我们为游戏设置 canvas 背景色。

初始化

这里我们到 game.js 文件。首先,我们需要声明一些在整个游戏期间引用的 全局变量。这些变量代表将控制游戏行为的特定属性。我们将通过一个叫 init函数 初始化这些属性。一个函数相当于通过运行一些语句执行一个特定任务,这里的任务时变量初始化。

最初添加下面的代码到 game.js 文件里:

let width;
let height;
let tileSize;
let canvas;
let ctx;

// 游戏对象初始化。
function init() {

    tileSize = 20;

    // 动态控制 canvas 大小。
    width = tileSize * Math.floor(window.innerWidth / tileSize);
    height = tileSize * Math.floor(window.innerHeight / tileSize);

    canvas = document.getElementById("game-area");
    canvas.width = width;
    canvas.height = height;
    ctx = canvas.getContext("2d");

}

变量 widthheight 保存 canvas 的宽高。canvas 变量保存 HTML canvas 元素的引用。ctxcanvas 上下文的缩写,它确定我们工作的坐标系统。在我们的例子中,我们将使用 2D 坐标。

tileSize 变量是游戏中不可缺少的元素。它是屏幕上的基础单位尺寸。为了实现蛇和食物的完美对齐,我们将整个屏幕分成网格,每个尺寸对应 tileSize。这也是为什么我们让 canvaswidthheight 最接近 tileSize 倍数的原因。

食物

我们需要一个将被蛇吃掉的食物的引用。我们将考虑它作为一个有特定属性和行为的对象,和现实世界的对象很相似。为了实现这个,我们将涉猎一些基本的 OOP(Object Oriented Programming, 面向对象编程)。

我们将创建一个叫 Food 如下:

// 视食物为对象
class Food {

    // 对象属性初始化
    constructor(pos, color) {

        this.x = pos.x;
        this.y = pos.y;
        this.color = color;

    }

    // 在 canvas 上画食物
    draw() {

        ctx.beginPath();
        ctx.rect(this.x, this.y, tileSize, tileSize);
        ctx.fillStyle = this.color;
        ctx.fill();
        ctx.strokeStyle = "black";
        ctx.lineWidth = 3;
        ctx.stroke();
        ctx.closePath();

    }

}

JS 中的类由 constructor 方法和一些成语函数组成,前者负责初始化基于它的对象的属性,后者定义行为。

这里我们使用参数化的构造函数提供带位置和颜色信息的食物对象。位置 pos 依次有属性 xy 确定 canvas 上的 X 和 Y 坐标。this 关键字用来指向当前类的实例(或对象),也就是我们指向当前考虑的对象的属性。当我们创建对象时会更清晰。

这里被用到的成员函数是 draw,它负责在 canvas 上画食物。draw 函数能拥有任何在 canvas 上画食物的代码段但是为简单起见,我们用一个红色方块代表食物,它有位置 xy 及宽高 tileSize。所有写在函数里的代码负责精确做到,在 canvas 上画一个红色方块。

最后,我们需要添加 food 对象到全局变量序列里并且在 init 函数里创建食物对象如下:

// 其他全局变量。

let food;

init 函数:

// 游戏中的对象初始化
function init() {

    tileSize = 20;

    // 动态控制 canvas 大小
    width = tileSize * Math.floor(window.innerWidth / tileSize);
    height = tileSize * Math.floor(window.innerHeight / tileSize);

    canvas = document.getElementById("game-area");
    canvas.width = width;
    canvas.height = height;
    ctx = canvas.getContext("2d");

    food = new Food(spawnLocation(), "red");
}

你也许在想 spawnLocation 是什么。它是为食物产生而返回一个 canvas 上随机位置的函数。代码如下:

// 在网格中确定一个随机的产生位置。
function spawnLocation() {

    // 将整个 canvas 分成块状网格
    let rows = width / tileSize;
    let cols = height / tileSize;

    let xPos, yPos;

    xPos = Math.floor(Math.random() * rows) * tileSize;
    yPos = Math.floor(Math.random() * cols) * tileSize;

    return { x: xPos, y: yPos };

}

蛇可能是游戏中最重要的方面。类似于 food 对象基于 Food 类,我们将创建名叫 Snake 的类,它包含蛇的属性和行为。 Snake 类如下:

class Snake {

    // 对象属性初始化。
    constructor(pos, color) {

        this.x = pos.x;
        this.y = pos.y;
        this.tail = [{ x: pos.x - tileSize, y: pos.y }, { x: pos.x - tileSize * 2, y: pos.y }];
        this.velX = 1;
        this.velY = 0;
        this.color = color;

    }

    // 在 canvas 上画蛇。
    draw() {

        // 画蛇头。
        ctx.beginPath();
        ctx.rect(this.x, this.y, tileSize, tileSize);
        ctx.fillStyle = this.color;
        ctx.fill();
        ctx.strokeStyle = "black";
        ctx.lineWidth = 3;
        ctx.stroke();
        ctx.closePath();

        // 画蛇尾。
        for (var i = 0; i < this.tail.length; i++) {

            ctx.beginPath();
            ctx.rect(this.tail[i].x, this.tail[i].y, tileSize, tileSize);
            ctx.fillStyle = this.color;
            ctx.fill();
            ctx.strokeStyle = "black";
            ctx.lineWidth = 3;
            ctx.stroke();
            ctx.closePath();

        }


    }

    // 以更新位置方式移动蛇
    move() {

        // 蛇尾移动。   
        for (var i = this.tail.length - 1; i > 0; i--) {

            this.tail[i] = this.tail[i - 1];

        }

        // 更新尾的第一个元素以或得头的位置
        if (this.tail.length != 0)
            this.tail[0] = { x: this.x, y: this.y };

        // 蛇头移动   
        this.x += this.velX * tileSize;
        this.y += this.velY * tileSize;

    }

    // 改变蛇的移动方向
    dir(dirX, dirY) {

        this.velX = dirX;
        this.velY = dirY;

    }

    // 判断蛇是否吃到了食物
    eat() {

        if (Math.abs(this.x - food.x) < tileSize && Math.abs(this.y - food.y) < tileSize) {

            // 添加到蛇尾
            this.tail.push({});
            return true;
        }

        return false;

    }

    // 检测蛇是否死了
    die() {

        for (var i = 0; i < this.tail.length; i++) {

            if (Math.abs(this.x - this.tail[i].x) < tileSize && Math.abs(this.y - this.tail[i].y) < tileSize) {
                return true;
            }

        }

        return false;

    }

    border() {

        if (this.x + tileSize > width && this.velX != -1 || this.x < 0 && this.velX != 1)
            this.x = width - this.x;

        else if (this.y + tileSize > height && this.velY != -1 || this.velY != 1 && this.y < 0)
            this.y = height - this.y;

    }

}

这个类包含大量代码,所以我们从方法一个一个来。

首先,我们有参数化构造函数,它以变量 xy 初始化蛇头的 X 和 Y 坐标、以 color 初始化蛇的颜色,以及以 velXvelY 确定 X 和 Y 方向上的速度。我们还有 tail 变量,它是一个对象列表,保存关于蛇尾的片段。蛇尾被初始设置有两段,其 X 和 Y 坐标被它的 xy 属性所确定。

现在,我们集中注意力到类的不同成员方法上:

  • draw 函数:draw 函数和 Food 中的类似。它负责在 canvas 上画蛇。同样,我们能用任何事物代表蛇,但是为了简单,我们使用如 tileSize 尺寸的绿色方块代表蛇头和每个蛇尾片段。函数里的代码做的实际是,画一些绿方块在 canvas 上。

  • move 函数:蛇移动的主要挑战是蛇尾的适当运动。我们需要能保存不同蛇尾片段的位置,以让蛇沿着某个路径。这是通过给蛇尾片段赋值为前一个片段位置实现的。这样蛇尾沿着蛇头曾走过路径走下去。蛇的位置通过速度 velXvelYtileSize 增加,tileSize 是网格基本单位。

  • dir 函数:dir 函数的目的是改变蛇头的运动方向。我们一会儿会讲到这个。

  • eat 函数:eat 函数负责检查蛇是否吃到了一块食物。这通过查找蛇头和食物的重叠实现。由于 tileSize 对应网格尺寸,我们可以检查头部和食物位置的差异是否对应 tileSize,并相应地返回 truefalse。在此基础上,我们还在蛇尾上增加了一段,使其长度增加。

  • die 函数:我们的蛇将会死亡仅当它咬它尾部某个部分。那是我们在这个函数里检查的东西,即如果头和尾的某部分重叠。因此,我们返回 truefalse 作为回应。

  • border 函数:border 函数检查是否蛇在屏幕边界内。如果蛇从屏幕的一边消失了,那就太奇怪了。这里我们可以做以下两件事中的任何一件;我们既可以在那里结束游戏,也可以让蛇神奇地出现在屏幕的另一端,类似于经典的贪吃蛇游戏。我们选择了第二种方法,也就是函数内部的代码。

我们需要为蛇做最后一件事。我们将在全局变量序列下声明一个如下蛇对象:

let snake;

并在 init 函数内初始化如下:

snake = new Snake({ x: tileSize * Math.floor(width / (2 * tileSize)), y: tileSize * Math.floor(height / (2 * tileSize)) }, "#39ff14");

游戏循环

在更进一步之前,我们需要定义一个运行此游戏的函数。所以我们定义它如下:

// 实际的游戏函数。
function game() {

    init();

}

在这个函数里,我们调用 init 函数,它仅处理全局变量初始化。如何在 canvas 上画对象和持续运行游戏呢?这就是游戏循环的由来。

游戏循环或者重复执行的逻辑被写下一个函数里,名字叫 update。此 update 函数定义如下:

// 更新位置和重绘游戏中的对象。
function update() {

        if (snake.die()) {
            alert("GAME OVER!!!");
            window.location.reload();
        }

        snake.border();

        if (snake.eat()) {
            food = new Food(spawnLocation(), "red");
        }

        // 为重绘清屏 canvas。
        ctx.clearRect(0, 0, width, height);

        food.draw();
        snake.draw();
        snake.move();

}

update 函数将逐帧处理更新游戏逻辑,即画蛇,食物和移动蛇。它还检测蛇是否吃了食物或者它已死亡。如果蛇死了,我们将重新加载游戏,如逻辑所描述。

现在我们只剩下在某些特定时间间隔后重复调用 update 函数的任务。在任何其他事项前,我们需要讨论 FPS 或者帧每秒。宽松定义,它指的是游戏屏幕每秒渲染次数。传统的贪吃蛇游戏有一个低帧率,大约10 FPS,我们将沿用它。

我们定义一个变量叫 fps 在全局变量序列下并在 init 函数里初始化为 10。

然后我们更新 game 函数的代码如下:

// 实际的游戏函数。
function game() {

    init();

    // 游戏循环。
    interval = setInterval(update,1000/fps);

}

setInterval 函数在特定毫秒数后周期性调用某个函数。我们把这个引用保存在叫 interval 的变量里。

最后,当蛇死亡,我们需要通过调用 clearInterval 函数清除这个 interval 如下:

if (snake.die()) {
     alert("GAME OVER!!!");
     clearInterval(interval);
     window.location.reload();
}

这样,我们的游戏循环就准备好了。

后勤工作

现在我们准备好了游戏循环,我们需要有一个系统计算玩家得分和提供暂停游戏功能。

我们将定义两个全局变量 scoreisPaused 并在 init 函数内初始化它们如下:

score = 0;
isPaused = false;

我们然后定义两个函数在 canvas 上展示分数和游戏状态如下:

// 展示玩家得分。
function showScore() {

    ctx.textAlign = "center";
    ctx.font = "25px Arial";
    ctx.fillStyle = "white";
    ctx.fillText("SCORE: " + score, width - 120, 30);

}

// 展示游戏是否暂停
function showPaused() {

    ctx.textAlign = "center";
    ctx.font = "35px Arial";
    ctx.fillStyle = "white";
    ctx.fillText("PAUSED", width / 2, height / 2);

}

我们添加下面的代码到 udpate 函数开头:

if(isPaused){
   return;
}

并在 update 末尾调用 showScore 函数如下:

showScore();

update 函数内的 snake.eat 下添加:

score += 10;

键盘操控

玩家需要能够同游戏交互。为此,我们需要添加 事件监听 到代码里。这些监听将有 回调 函数,它们将查找按键和执行代码以控制游戏,如下:

// 为按键添加一个事件监听。
window.addEventListener("keydown", function (evt) {
    if (evt.key === " ") {
        evt.preventDefault();
        isPaused = !isPaused;
        showPaused();
    }
    else if (evt.key === "ArrowUp") {
        evt.preventDefault();
        if (snake.velY != 1 && snake.x >= 0 && snake.x <= width && snake.y >= 0 && snake.y <= height)
            snake.dir(0, -1);
    }
    else if (evt.key === "ArrowDown") {
        evt.preventDefault();
        if (snake.velY != -1 && snake.x >= 0 && snake.x <= width && snake.y >= 0 && snake.y <= height)
            snake.dir(0, 1);
    }
    else if (evt.key === "ArrowLeft") {
        evt.preventDefault();
        if (snake.velX != 1 && snake.x >= 0 && snake.x <= width && snake.y >= 0 && snake.y <= height)
            snake.dir(-1, 0);
    }
    else if (evt.key === "ArrowRight") {
        evt.preventDefault();
        if (snake.velX != -1 && snake.x >= 0 && snake.x <= width && snake.y >= 0 && snake.y <= height)
            snake.dir(1, 0);
    }

});

上面的 dir 函数代码指定蛇的移动方向。我们制定如下约定;

向上和向下移动分别对应-1和1对于 Y 速度,向左和向右分别被-1和1代表对于 X 速度。evt.key 属性表示被按下的键名,对监听器。这样,我们现在能用方向键控制蛇和用空格键暂停游戏。

完结撒花

现在所有事就位,我们将添加最后的功能片段到我们代码。当 HTML 文档在浏览器完成加载时我们就加载游戏。为此,我们将添加另一个检查文档是否以及载入的事件监听。代码如下:

// 加载浏览器窗口
window.addEventListener("load",function(){

     game();

});

看!我们的游戏应该开始运行,当我们在浏览器启动 index.html 文件的时候。

资源

Soupaul / Snake-Game

一个使用 HTML 和纯 JS 实现的经典贪吃蛇翻版。

更新过的仓库分支包含一些附加内容到代码以使游戏更美观,健壮和顺滑。我们还加了一些检查以避免未知 bug。

你可以在 这里 玩此游戏。

我们希望你能发现深刻见解。

访问我们的 网站 了解更多关于我们的信息,也可以关注我们:

另外,别忘了在下面点赞和评论如果你对学习更多有关使用 Javascript 的游戏开发。你可以自由的提出疑问和改进建议。

那时,

保持安全,愿源头与你同在!

image.png