将-Arduino-连接到-Web-三-

140 阅读1小时+

将 Arduino 连接到 Web(三)

原文:Connecting Arduino to the Web

协议:CC BY-NC-SA 4.0

八、创建 Web 仪表板

您可以将传感器连接到 Arduino,并将数据发送到前端,以创建物联网仪表盘。在本章中,你将使用热、光和湿度传感器来收集数据,然后显示在网页上。仪表板上的可视化将对实时数据做出反应,这些数据将被存储,以给出每日的最高和最低读数。通过以这种方式使用数据可视化,您可以使数据更容易阅读、理解和分析。

仪表板

本章中的仪表板将获取温度、湿度和光照水平数据,并在环形图中显示每个数据。数据将存储在服务器上,存储在一个简单的 JavaScript 对象中,每天都会重置。每次值改变时,对象将被传递到前端,以便仪表板显示数据的准确图片。图 8-1 显示了仪表板在浏览器中的外观。

A453258_1_En_8_Fig1_HTML.jpg

图 8-1

The dashboard application for this chapter

数据可视化原理

我们将数据可视化,以便更好地理解它,无论是探索数据、传达信息还是用数据讲述故事。数据可视化由点、线、区域、表面或体积组成。这些可以被修改成 Jacques Bertin 所说的视觉变量。他定义了七个视觉变量:位置、大小、价值、纹理、颜色、方向和形状。图 8-2 显示了七个视觉变量。

A453258_1_En_8_Fig2_HTML.png

图 8-2

The seven visual variables and how they are related to points, lines, and area

随着时间的推移,其他研究人员加入了这些视觉变量。您使用哪些可视变量来表示数据将取决于数据的类型。数量型、序数型和分类型数据适用于某些变量。

定量数据是有数量的数据,例如,一袋中苹果的数量。有序数据是具有我们给定的顺序的数据,例如,你的前 10 部电影。分类数据用于标注,没有与之相关的数字,例如,国家列表。

有许多不同类型的图表可以用来表示数据,您选择哪种类型取决于您拥有的数据类型和您想要表达的内容。当选择您想要创建的可视化类型时,考虑谁将查看它,它需要有什么样的复杂程度,以及可视化是否使数据易于理解。

可视化的类型

您可以制作多种类型的图表来可视化数据,其中一些如图 8-3 所示。

A453258_1_En_8_Fig3_HTML.png

图 8-3

1. Clustered force layout, 2. Cluster dendrogram, 3. Scatter plot. Visualizations of data from https://census.gov/data/tables/2016/demo/popest/total-cities-and-towns.html showing population estimates for 2016 for the 20 highest populated cites in the United States

标记可视化

在可视化上有正确的标签是非常重要的。你可以从一个好的标题开始,这个标题要与可视化显示的内容相匹配。拥有数据的键也很重要

颜色

您使用的颜色可能会模糊数据的含义。关于颜色,有几件事需要考虑。首先,颜色对不同的人有不同的意义,你不应该假设,因为你把一种颜色和某种意义联系起来,其他人也会。当你考虑你的浏览器时,你应该考虑这一点。

如果您使用颜色来表示某个范围的值,请确保相似的值不会有非常不同的颜色。观众会认为你试图突出非常不同的价值观。

有一个非常有用的在线工具叫做 ColorBrewer http://colorbrewer2.org/ ,它是用来帮助选择制图颜色的。它将为您的可视化提供良好的颜色值,并且它还有一个色盲安全模式。

在附录 B 中,我列出了一些数据可视化的好资源。

传感器

本章将使用温湿度传感器和光敏电阻。有许多公司生产这些类型的传感器;我用过 Elegoo 生产的。

DHT11 温度和湿度传感器

这是一个测量温度和相对湿度的数字传感器。相对湿度是指空气中的水量与特定温度下空气中的水量之比。温度是用摄氏度来测量的。

光敏电阻

光敏电阻对光线有反应。当环境中的光强度增加时,电阻减小。它连接到一个模拟引脚,因此值在 0 到 1023 之间。亮度越高,输出越接近 0。

导入库

无论您决定使用哪种品牌的温度和湿度传感器,您都可能需要在 Arduino IDE 中安装一个温度和湿度传感器库。Elegoo 传感器有需要安装的可下载 ZIP 文件。这些后续步骤将介绍如何为 Elegoo 实现这一点;但是你可能会发现另一种传感器的不同步骤。

  1. 打开 Arduino IDE。
  2. 在菜单中,转到草图/包含库/添加。ZIP 库,将会打开一个窗口。
  3. 导航到 ZIP 文件,双击该文件将其导入。
  4. 您现在应该能够看到导入的库,因此通过查看菜单“草图/包含库”进行检查,您应该能够在库列表中看到库的名称。

Note

不同类型的温度和湿度传感器有不同的可下载库。如果您使用了不同品牌的传感器,您将使用其库,因此 ino 代码会略有不同。请参考您的传感器指南,找到正确的代码。

Set Up the Temperature and Heat Sensors

要设置温度和湿度,您需要一个温度和湿度传感器、一个 Arduino Uno、一根 USB 电缆和一根母线对公线。图 8-4 显示了组件。

A453258_1_En_8_Fig4_HTML.jpg

图 8-4

The components needed to set up the temperature and humidity sensor: 1. Breadboard, 2. DHT11 temperature and humidity sensor, 3. Arduino Uno

图 8-5 显示了部件应该如何连接。

A453258_1_En_8_Fig5_HTML.jpg

图 8-5

Connecting the components to the Arduino Arduino Code

ino 代码将导入传感器库,并使用该库的 dht11.read()函数导入传感器数据。打开 Arduino IDE,创建一个名为 chapter_08.ino 的新草图,并复制清单 8-1 中的代码。

#include <SimpleDHT.h>
int pinTempHumidity = 2;
SimpleDHT11 dht11;
byte temperature = 0;
byte humidity = 0;
byte data[40] = {0};
void setup() {
  Serial.begin(9600);

}
void loop() {
  Serial.println("Current Reading");
  dht11.read(pinTempHumidity, &temperature, &humidity, data);
  Serial.print((int)temperature); Serial.print(" *C, ");
  Serial.print((int)humidity); Serial.println(" %");
  delay(10000);
}
Listing 8-1chapter_08.ino

代码解释表 8-1 解释了 chapter_08.ino 中的代码。

表 8-1

chapter_08.ino explained

| `#include ` | 这包括使用传感器所需的导入的 SimpleDHT 库。 | | `int pinTempHumidity = 2;` | 为传感器使用的 pin 号创建一个变量。 | | `SimpleDHT11 dht11;` | 创建一个变量来保存来自 SimpleDHT11 类型的传感器的数据。 | | `byte temperature = 0;``byte humidity = 0;` | 创建三个变量来保存从传感器返回的字节数据。 | | `dht11.read(pinTempHumidity, &temperature, &humidity, data);` | dht11.read()函数获取传感器所连接的引脚号,并返回传感器的温度、湿度和字节数据。 | | `Serial.print((int)temperature);Serial.print(" *C, ");` `Serial.print((int)humidity);Serial.println(" %");` | Serial.print()函数在温度和湿度将字节数据转换为整数之前,打印来自传感器的值。 |

验证代码,然后通过 USB 将 Arduino 连接到端口,将草图上传到 Arduino。确保您在工具菜单中为 Arduino 选择了正确的端口:工具/端口。

现在为你的草图打开串行监视器,你应该看到每 10 秒钟就有一次数据传来。

Adding a Photoresistor

从电脑上拔下 Arduino 以设置光敏电阻。更新后的设置如图 8-6 所示。

A453258_1_En_8_Fig6_HTML.jpg

图 8-6

The setup for the photoresitor

打开。并使用清单 8-2 中的粗体代码对其进行更新。

#include <SimpleDHT.h>
int pinTempHumidity = 2;
SimpleDHT11 dht11;
byte temperature = 0;
byte humidity = 0;
byte data[40] = {0};
int  pinLight  =  A0;    

int  valueLight =  0;

void setup() {
  Serial.begin(9600);
}
void loop() {
  Serial.println("Current Reading");
  dht11.read(pinTempHumidity, &temperature, &humidity, data);
  valueLight =  analogRead(pinLight);

  Serial.print((int)temperature); Serial.print(" *C, ");
  Serial.print((int)humidity); Serial.println(" %");
  Serial.print(valueLight, DEC);
  delay(10000);
}

Listing 8-2Updated chapter_08.ino

代码解释

表 8-2 解释了 chapter_08.ino 中的更新代码。

表 8-2

chapter_08.ino updated

| `int  pinLight  =  A0;` `int  valueLight =  0;` | 创建了两个新变量来保存来自光敏电阻的管脚号和值。 | | `valueLight =  analogRead(pinLight);` | 电阻器的值存储在变量值灯中。 | | `Serial.print(valueLight, DEC);` | 打印该值,DEC 是 Serial.print()函数的可选参数,它确保打印的值是十进制的。 |

将更新的草图上传到 Arduino,并在 Arduino IDE 中打开串行监视器;你应该从光敏电阻上看到它的价值。

Update the Arduino Sketch

如果您可以在串行监视器中看到来自温湿度传感器和光敏电阻的数据,则传感器设置正确。现在,您可以编写一个草图来格式化 Node.js 服务器的数据。草图有两个主要变化:首先,serial.print 函数需要发送用逗号分隔的所有数据。第二,来自光敏电阻的数据将被映射,因此数值将从低光的 0 到高光的 10。创建一个新草图,并复制清单 8-3 中的代码。

#include <SimpleDHT.h>
int pinTempHumidity = 2;
SimpleDHT11 dht11;
byte temperature = 0;
byte humidity = 0;
byte data[40] = {0};

int  pinLight  =  A0;    
int  valueLight =  0;

void setup() {
  Serial.begin(9600);
}
void loop() {
  dht11.read(pinTempHumidity, &temperature, &humidity, data);
  valueLight =  analogRead(pinLight);
  valueLight = map(valueLight, 0, 1023, 10, 0);

  Serial.println((String)temperature + "," + (String)humidity + "," + (String)valueLight);
  delay(500);
}

Listing 8-3chapter_08_final.ino

代码解释

表 8-3 解释了 chapter_08_final.ino 中的代码。

表 8-3

chapter_08_final.ino explained

| `valueLight = map(valueLight, 0, 1023, 10, 0);` | 一旦光敏电阻的值被读入变量值 Light,它就被映射成一个新值。当它从模拟引脚读取时,它将是一个介于 0 和 1023 之间的值,数字越大,亮度越低。贴图将采用这个数字,并将其转换为 0 到 10 之间的数字,0 表示较低的光线。 | | `Serial.println((String)temperature + "," + (String)humidity + "," + (String)valueLight);` | 每个值都被转换成一个字符串,这样就可以用逗号将它与其他值连接起来。在 Node.js 服务器中使用逗号来确定每个新数据的起始位置。 |

The Dashboard Application

现在传感器已经设置好了,您可以为数据创建仪表板应用程序了。首先构建框架应用程序,其结构如下所示:

/chapter_08
    /node_modules
    /public
        /css
            main.css
        /javascript
            main.js
            donut.js
    /views
        index.ejs
    index.js

创建服务器的设置与前面章节中的相同:

  1. 创建一个新文件夹来存放应用程序。我叫我的章 _08。
  2. 打开命令提示符(Windows 操作系统)或终端窗口(Mac)并导航到新创建的文件夹。
  3. 在正确的目录中,键入 npm init 创建一个新的应用程序;您可以按下 return 键浏览每个问题或对其进行更改。
  4. 您现在可以开始添加必要的库;要在命令行下载 Express.js,请键入 npm install express@4.15.3 - save。
  5. 然后安装 ejs,键入 npm install ejs@2.5.6 - save。
  6. 下载完成后,安装串口。在 Mac 上,键入 NPM install serial port @ 4 . 0 . 7–save;在 Windows PC 上,键入 NPM install serial port @ 4 . 0 . 7-build-from-source。
  7. 然后最后安装 socket.io,输入 npm install socket.io@1.7.3 - save。

Set Up the Node.js Server

来自 Arduino 的数据有三部分:温度、湿度和亮度。它们将以逗号分隔的单个字符串的形式出现。字符串内容将被放入一个数组,然后可以传递到前端。字符串末尾将包含一个换行符,需要将其删除。打开应用程序的 index.js 文件,复制清单 8-4 中的代码。

var http = require('http');
var express = require('express');
var app = express();
var server = http.createServer(app);
var io = require('socket.io')(server);
var SerialPort = require('serialport');
var serialport = new SerialPort('<add in the serial port for your Arduino>', {
    parser: SerialPort.parsers.readline('\n')
});
app.engine('ejs', require('ejs').__express);
app.set('view engine', 'ejs');
app.use(express.static(__dirname + '/public'));
app.get('/', function (req, res){
    res.render('index');
});
io.on('connection', function(socket){
    console.log('socket.io connection');
    serialport.on('data', function(data){
        data = data.replace(/(\r\n|\n|\r)/gm,"");
        var dataArray = data.split(',');
        socket.emit("data", dataArray);
    });
    socket.on('disconnect', function(){
        console.log('disconnected');
    });
});
server.listen(3000, function(){
    console.log('listening on port 3000...');
});
Listing 8-4index.js

确保您更改的代码包含 Arduino 所连接的串行端口。

代码解释

表 8-4

index.js explained

| data = data . replace(/(\ r \ n | \ n | \ r)/GM," "); | 正则表达式将使用 replace()函数删除任何换行符。 | | var dataArray = data.split(','); | split 函数获取数据字符串,并在每次遇到逗号时将其拆分,然后创建每个单词的数组。 |

表 8-4 解释了 index.js 中的代码。

Create the Web Page

现在,您将创建包含仪表板的基本页面。它将有一个套接字,以便您可以测试数据是否到达前端。在 views 文件夹中打开或创建 index.ejs 文件,并复制清单 8-5 中的代码。

<!DOCTYPE html>
<head>
    <title>Dashboard</title>
</head>
<body>
    <div class="wrapper">
        <h1>Dashboard</h1>
        <p>This page will contain a dashboard of data</p>
    </div>
    <script src="https://cdn.socket.io/socket.io-1.2.0.js"></script>
    <script>
        var socket = io();
        socket.on("data", function(data){
            console.log(data);
        });
    </script>
</body>
</html>
Listing 8-5index.ejs

确保 Arduino 已连接到您的电脑,但串行监视器已关闭。在控制台中转到应用程序的根目录,键入 nodemon index.js 或 node index.js 来启动服务器。转到 http://localhost:3000 并打开页面。您应该看到保持页面,然后开始看到数据进入控制台。如果您打开浏览器的开发工具(在 mac 上为 Option + Command + i,在 Windows PC 上为 CTRL + Shift + i),您还应该在控制台选项卡中看到数据。

Create the Donut Charts

该控制面板有两个主要元素,圆环图和当天的高/低数据。圆环图将使用 D3.js 创建,并且将是 180 度而不是 360 度。有三个图表,每种类型的数据一个。创建图表的代码将位于一个名为 donut.js 的独立 JavaScript 文件中;它使用显示模块模式。圆环图本身是在 main.js 文件中创建的,从 donut.js 调用方法。这使代码保持独立,意味着您可以使用相同的代码创建所有图表。donut.js 文件包含许多创建和更新甜甜圈的方法。

更新 index.js

进入 Node.js 服务器的数据将保存在一个对象中。这个对象将被传递到前端。打开清单 8-4 中的代码,用清单 8-6 中的粗体代码更新它。

var http = require('http');
var express = require('express');
var app = express();
var server = http.createServer(app);
var io = require('socket.io')(server);
var SerialPort = require('serialport');
var serialport = new SerialPort('<add in the serial port for your Arduino>', {
    parser: SerialPort.parsers.readline('\n')
});

var sensors = {

    temp: {current: 0 , high:0, low:100 },
    humidity: {current: 0, high:0, low: 100},
    light: {current: 0, high:0, low: 10}

}

app.engine('ejs', require('ejs').__express);
app.set('view engine', 'ejs');

app.use(express.static(__dirname + '/public'));

app.get('/', function (req, res){

    res.render('index');
});

io.on('connection', function(socket){
    console.log('socket.io connection');

    socket.emit("initial-data", sensors);

    serialport.on('data', function(data){
        data = data.replace(/(\r\n|\n|\r)/gm,"");
        var dataArray = data.split(',');

        var hasChanged = updateValues(dataArray);
        if (hasChanged > 0){
           socket.emit("data", sensors);
        }

});
    socket.on('disconnect', function(){
        console.log('disconnected');
    });
});
server.listen(3000, function(){
    console.log('listening on port 3000...');
});

function updateValues(data){

    var changed = 0;
    var keyArray = ["temp", "humidity", "light"];

    keyArray.forEach(function(key, index){

        var tempSensor = sensors[key];
        var newData = data[index];
        if(tempSensor.current !== newData){
            sensors[key].current = data[index];
            changed = 1;
        }
        if(tempSensor.high < newData){
            sensors[key].high = data[index];
            changed = 1;
        }

        if(tempSensor.low > newData ){
            sensors[key].low = data[index];
        }
    });
    return changed;

}

Listing 8-6Updated index.js

代码解释

表 8-5 解释了 index.js 中的代码。

表 8-5

index.js explained

| `var sensors = {``temp: {current: 0 , high:0, low:100 },``humidity: {current: 0, high:0, low: 100},``light: {current: 0, high:0, low: 10}` | 变量 sensor 保存一个对象,该对象包含传感器的当前值以及最高和最低值。由于对象存储在服务器上,它将存储这些值,直到服务器重新启动。当来自传感器的新数据进来时,该对象被更新。 | | `socket.emit("initial-data", sensors);` | 当浏览器连接到服务器时,当前传感器数据被发送给它。 | | `var hasChanged = updateValues(dataArray);` | 变量 hasChanged 将保存函数 updateValues()的返回值。如果数据没有改变,变量 hasChanged 将为 0,如果数据改变,变量 has changed 将为 1。 | | `if (hasChanged > 0){``socket.emit("data", sensors);` | 如果 hasChanged 大于 0,则数据已更改,调用 socket.emit,id 为“data”,将更新的传感器数据传递给前端。 | | `function updateValues(data){}` | updateValues()函数被传递给新数据,它对照 sensor 对象检查新数据,看是否有任何值发生了更改。 | | `var changed = 0;` | 它将名为的变量初始化为 0;这是将在函数结束时返回的变量。 | | `var keyArray = ["temp", "humidity", "light"];` | 创建要测试的传感器阵列。 | | `keyArray.forEach(function(key, index){` `});` | JavaScript forEach()函数被调用来遍历数组,它将使用数组项的数据作为键,使用数组项的位置作为索引。 | | `var tempSensor = sensors[key];` `var newData = data[index];` | 创建两个变量来保存键和索引的值。 | | `if(tempSensor.current !== newData){``sensors[key].current = data[index];``changed = 1;` | if 语句检查数据的当前值是否不等于新数据。如果数据改变了 sensors 对象中特定传感器的当前值,则更新 sensor 对象,并将变量 changed 设置为 1。还有 if 语句来检查高值和低值是否也发生了变化。 | | `return changed;` | 然后返回已更改的变量。如果这些值都没有改变,那么代码就不会输入任何 If 语句,changed 返回 0。如果任何值已经更改,那么 changed 将返回 1。 |

Create the Donut Javascript

创建圆环图的代码将拥有自己的 JavaScript 文件。在 public/javascript 文件夹中创建一个名为 donut.js 的文件,并复制清单 8-7 中的代码。

var DonutChart = function(){
    var pi = Math.PI;
    var sensorDomainArray;
    var divIdName;
    var sensorAmount;
    var sensorText = "";

    var sensorScale;
    var foreground;
    var arc;
    var svg;
    var g;
    var textValue;
    function setSensorDomain(domainArray){
        sensorDomainArray = domainArray;
    }
    function setSvgDiv(name){
        divIdName = name;
    }
    function createChart(sensorTextNew, sensorType){
        sensorText = sensorTextNew;
        var margin = {top: 10, right: 10, bottom: 10, left: 10};
        var width = 240 - margin.left - margin.right;
        var height = 200;
        sensorScale = d3.scaleLinear()
            .range([0, 180]);
        arc = d3.arc()
            .innerRadius(70)
            .outerRadius(100)

            .startAngle(0);
        svg = d3.select(divIdName).append("svg")
            .attr("width", width + margin.left + margin.right)
            .attr("height", height + margin.top + margin.bottom);
        g = svg.append("g").attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
        g.append("text")
            .attr("text-anchor", "middle")
            .attr("font-size", "1.3em")
            .attr("y", -20)
            .text(sensorType);
        textValue = g.append("text")
         .attr("text-anchor", "middle")
         .attr('font-size', '1em')
         .attr('y', 0)
         .text(sensorAmount + "" +  sensorText);
        var background = g.append("path")
            .datum({endAngle: pi})
            .style("fill", "#ddd")
            .attr("d", arc)
            .attr("transform", "rotate(-90)")
        foreground = g.append("path")
            .datum({endAngle: 0.5 * pi})
            .style("fill", "#FE8402")
            .attr("d", arc)
            .attr("transform", "rotate(-90)");
    }
    function updateChart(newSensorValue){
        sensorScale.domain(sensorDomainArray);
        var sensorValue = sensorScale(newSensorValue);
        sensorValue = sensorValue/180;
        textValue.text(newSensorValue + "" +  sensorText);
        foreground.transition()
          .duration(750)
          .attrTween("d", arcAnimation(sensorValue * pi));
    }
    function arcAnimation(newAngle)

      return function(d) {
        var interpolate = d3.interpolate(d.endAngle, newAngle);

        return function(t) {
          d.endAngle = interpolate(t);
          return arc(d);
        };
      };
    }
    return{
        setSensorDomain: setSensorDomain,
        setSvgDiv: setSvgDiv,
        createChart:createChart,
        updateChart: updateChart
    }
};

Listing 8-7donut.js

代码解释

该代码使用 D3.js arc 函数创建一个圆环图。通常这些将是 360,但在这种情况下,它将是 180。当从 Arduino 发送新数据时,将调用 updateChart()方法,该方法调用函数 arcAnimation(),该函数计算出新旧角度之间的动画。表 8-6 详细介绍了 donut.js。

表 8-6

donut.js explained

| `var DonutChart = function(){` `};` | 所有的代码都包装在一个函数中;这是揭示模块模式的一部分。可以通过调用新的 donutChart()来创建新的甜甜圈。 | | `function setSensorDomain(domainArray){``sensorDomainArray = domainArray;` | 此函数设置圆环图的域。该域是数据的最高和最低可能值的一组值。 | | `function setSvgDiv(name){``divIdName = name;` | 将 HTML div 的名称放入变量 divIdName 的函数。 | | `function createChart(sensorTextNew, sensorType){}` | 圆环图的初始设置包含在此方法中。传递给它两个参数:与数据类型一起使用的符号,例如,%;然后是它所代表的数据类型,温度、湿度或光线 | | `sensorScale = d3.scaleLinear()` `.range([0, 180]);` | sensorScale 保存可视化的范围和域。范围是 0 到 180,因为甜甜圈可以从 0 到 180。 | | `arc = d3.arc()``.innerRadius(70)``.outerRadius(100)` | d3.js arc()函数存储在代码顶部声明的变量 arc 中。您可以设置它的内半径和外半径,这将定义它的大小和圆环孔。 | | `var background = g.append("path")``.datum({endAngle: pi})``.style("fill", "#ddd")``.attr("d", arc)` | 创建的背景弧将始终为 180 度,并且是灰色的;它被旋转到水平位置。 | | `foreground = g.append("path")``.datum({endAngle: 0.5 * pi})``.style("fill", "#FE8402")``.attr("d", arc)` | 前景弧被创建,它有一个橙色填充。 | | `function updateChart(newSensorValue){}` | 当有新数据并且需要更新圆环图时,会调用此函数。它有一个参数,新值。 | | `sensorScale.domain(sensorDomainArray);` | 标度的定义域已设置。 | | `var sensorValue = sensorScale(newSensorValue);` | 采用新值并将其映射到圆环图刻度。 | | `sensorValue = sensorValue/180;` | 将值调整为 180。 | | `textValue.text(newSensorValue + "" +  sensorText);` | 更新文本值。 | | `foreground.transition()``.duration(750)` | 为圆环图创建持续时间为 750 毫秒的过渡。调用 arcAnimation()函数,它计算出弧线的过渡。 | | `function arcAnimation(newAngle) {``return function(d) {``var interpolate = d3.interpolate(d.endAngle, newAngle);``return function(t) {``d.endAngle = interpolate(t);``return arc(d);``};``};``}` | 向该函数传递新数据的新角度,并返回新旧角度之间的动画。 | | `return{` `setSensorDomain: setSensorDomain,` `setSvgDiv: setSvgDiv,` `createChart:createChart,` `updateChart: updateChart` | 返回一组可以在 donut.js 外部调用的方法,这些方法将在 main.js 中使用。 |

Create the Main.js File

在 public/javascript 文件夹中创建或打开 main.js 文件,并复制清单 8-8 中的代码。

(function(){
    var socket = io();
    var temperature = new DonutChart();
    temperature.setSensorDomain([-6,50]);
    temperature.setSvgDiv('#donut1');
    temperature.createChart('\u00B0'+"c", "temp");

    var humidity = new DonutChart();

    humidity.setSensorDomain([0,90]);
    humidity.setSvgDiv('#donut2');
    humidity.createChart('\u0025', "humidity");
    var light = new DonutChart();
    light.setSensorDomain([0,10]);
    light.setSvgDiv('#donut3');
    light.createChart('', "light");

    socket.on("initial-data", function(data){
        temperature.updateChart(data.temp.current);
        humidity.updateChart(data.humidity.current);
        light.updateChart(data.light.current);

});

    socket.on('data', function(data){
        temperature.updateChart(data.temp.current);
        humidity.updateChart(data.humidity.current);
        light.updateChart(data.light.current);
    });  
})();

Listing 8-8main.js

代码解释

这段代码将创建三个圆环图,并在有新数据时更新它们。有关 main.js 的更多详细信息,请参见表 8-7

表 8-7

main.js explained

| `var temperature = new DonutChart();` | 创建一个保存在名为 temperature 的变量中的新圆环图。 | | `temperature.setSensorDomain([-6,50]);` | 设置温度的域;调用 donut.js setSensorDomain()方法并传递可能的最低和最高温度。这是可能的温度范围。 | | `temperature.setSvgDiv('#donut1');` | donut.js setSvgDiv()方法被传递了将保存温度圆环图的 HTML div 的 id。 | | `temperature.createChart('\u00B0'+"c", "temp");` | 调用 donut.js createChart()方法,并向其传递表示度数符号的 Unicode 和表示摄氏度的字母 c 以及图表的类型。使用相同的方法创建湿度和光照圆环图。 | | `socket.on("initial-data", function(data){``temperature.updateChart(data.temp.current);``humidity.updateChart(data.humidity.current);``light.updateChart(data.light.current);` | 初始数据通过 socket.on()方法传递给前端,它调用 updateChart()方法并传入当前的温度、湿度或光线数据。 | | `socket.on('data', function(data){``temperature.updateChart(data.temp.current);``humidity.updateChart(data.humidity.current);``light.updateChart(data.light.current);` | 新数据通过 id 为“data”的 socket.on()传递到前端。这将调用 updateChart()方法并传入当前的温度、湿度或光线数据。 |

Update the Front End

从清单 8-5 中打开 index.ejs 代码并删除它;复制清单 8-9 中的代码。

<!DOCTYPE html>
<head>
    <meta charset="UTF-8">
    <title>Dashboard</title>
    <link href="/css/main.css" rel="stylesheet" type="text/css">
</head>
<body>
    <header>
        <h1>SENSOR DASHBOARD</h1>
        <h2>temperature humidity light</h2>
</header>
    <main>
        <h3>current values</h3>
        <div class="container">
            <div id="donut1" class="donut flex-child"></div>

            <div id="donut2" class="donut flex-child"></div>
            <div id="donut3" class="donut flex-child"></div>
        </div>
    </main>
    <script src="https://cdn.socket.io/socket.io-1.2.0.js"></script>
    <script src="https://d3js.org/d3.v4.js"></script>

    <script src="javascript/donut.js"></script>
    <script src="javascript/main.js"></script>
</body>
</html>

Listing 8-9index.ejs

代码解释

这段代码将为三个圆环图创建 div,这些 div 将在新数据进入时更新。它还包括 donut.js 脚本和 main.js 脚本。有关 index.ejs 的更多详细信息,请参见表 8-8

表 8-8

index.ejs explained

| `
``
` | 有三个 HTML div 标签,每个圆环图一个。 | | `` `` | public 文件夹中的两个脚本包含在内。需要首先调用 donut.js 脚本,因为 main.js 正在使用它。 |

Add the CSS

在 public/css 文件夹中打开或创建 main.css,并在列表 8-10 中添加 css。

*{
  margin: 0;
  padding: 0;
}

body{
  font-family: Verdana, Arial, sans-serif;
}
h2{
  font-size: 18px;
}
h3{
  font-size: 16px;
}
p{
  font-size: 14px;
}
header{
  background: #6BCAE2;

  color: white;
}
header h1{
  padding-top: 25px;
}
header h2{
  padding-bottom: 25px;
}
header h3{
  padding-bottom: 10px;
}
header h1,header h2, header h3, header p{
    padding-left: 12px;
}
main h3{
  font-weight: normal;
  margin: 20px;
}
.container{
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
    margin-top: 20px;
    justify-content: space-between;
}
.flex-child{
  margin: auto;
}

Listing 8-10main.css

通过在控制台窗口中导航到应用程序,并键入 nodemon index.js 或 node index.js 来启动应用程序,可以检查到目前为止页面的外观。确保 Arduino 已连接到您的电脑,并打开浏览器,然后转到 http://localhost:3000/查看应用程序的运行情况。

添加日常值

实时数据现在显示在仪表板上,但数据对象也存储传感器的最高和最低值,这些值也可以显示。目前,这些值将是服务器运行期间的值,但是通过一些更改,您可以在午夜重置这些值,使其成为每日的最高值和最低值。然后可以将这些值添加到仪表板中。您还可以向仪表板添加一个日期,该日期将在午夜更新。

为此,您将使用一个名为 node-schedule 的新库。这个库帮助你在 Node.js 中安排事件在特定的时间发生,它基于 cron 的思想,cron 是 unix 类型操作系统的时间调度器。要在 Node.js 服务器中使用它,您需要请求它,然后如图 8-7 所示使用它。

A453258_1_En_8_Fig7_HTML.png

图 8-7

Basic code for the node-schedule library

该系统由一系列星星组成,从左到右代表秒(可选)、分钟、小时、一个月中的日子、一个月和一周中的日子。图 8-7 中的代码将在每分钟过去 10 秒时记录文本。图 8-8 显示了呼叫的格式

A453258_1_En_8_Fig8_HTML.jpg

图 8-8

node-schedule format

这意味着:

schedule.scheduleJob('10 * * * * *),

将安排功能在每分钟过 10 秒时运行

schedule.scheduleJob('*/10 * * * * *),

将计划每 10 秒运行一次功能

schedule.scheduleJob('10 * * * *),

将安排一项功能在整点后每 10 分钟运行一次。请注意,只有 5 个条目,而不是 6 个;秒*是可选的,因为这里没有用到,所以省略了。

schedule.scheduleJob('*/10 * * * *),

将计划每 10 分钟运行一次功能

schedule.scheduleJob('* 0 * * *),

将安排一个函数在每天午夜运行,这是您将用来每天更新网页上的日期和重置每天的值。

Add the Node-Schedule Library

可以使用 npm 安装节点计划库。

  • 导航到应用程序目录并键入 npm 安装节点-计划@1.2.5

然后可以使用 var schedule = require(' node-schedule ')将该库添加到 index.js 文件中;

Update Index.js

打开清单 8-4 中更新后的 index.js 文件,添加粗体代码:

var http = require('http');
var express = require('express');
var app = express();
var server = http.createServer(app);
var io = require('socket.io')(server);
var SerialPort = require('serialport');
var serialport = new SerialPort('<add in the serial port for your Arduino>', {
    parser: SerialPort.parsers.readline('\n')
});

var schedule = require('node-schedule');

var sensors = {
    temp: {current: 0 , high:0, low:100 },
    humidity: {current: 0, high:0, low: 100},
    light: {current: 0, high:0, low: 10}
}

var changeDay = 0;

var j = schedule.scheduleJob('*/40 * * * * *', function(){

    for (key in sensors) {
        if (sensors.hasOwnProperty(key)) {
            sensors[key].current = 0;
            sensors[key].high = 0;
            sensors[key].low = 100;
        }
    }
    changeDay = 1;

});

app.engine('ejs', require('ejs').__express);
app.set('view engine', 'ejs');
app.use(express.static(__dirname + '/public'));

app.get('/', function (req, res){
    res.render('index');
});
io.on('connection', function(socket){
    console.log('socket.io connection');
    socket.emit("initial-data", sensors);
    serialport.on('data', function(data){
        data = data.replace(/(\r\n|\n|\r)/gm,"");
        var dataArray = data.split(',');
        var hasChanged = updateValues(dataArray);
        if (hasChanged > 0){
           socket.emit("data", sensors);
        }
        if(changeDay === 1){
            changeDay = 0;
            socket.emit('change-day', "true");
        }
});
    socket.on('disconnect', function(){
        console.log('disconnected');
    });
});
server.listen(3000, function(){
    console.log('listening on port 3000...');
});

您需要保留 updateValues()函数,我没有将它包含在本次更新中,因为它没有发生变化。

代码解释表 8-9 解释了 index.js 中的代码。

表 8-9

index.js update explained

| `var schedule = require('node-schedule');` | 创建一个变量来保存节点计划。 | | `var j = schedule.scheduleJob('* 0 * * * *', function(){});` | 创建一个在午夜调用函数的计划。 | | `for (key in sensors) {``if (sensors.hasOwnProperty(key)) {``sensors[key].current = 0;``sensors[key].high = 0;``sensors[key].low = 100;``}` | 使用 JavaScript for key in 函数遍历 sensors 对象;并为每个传感器重置电流、高值和低值。 | | `changeDay = 1;` | 将 changeDay 设置为 1,以便代码的其余部分知道有新的一天。 | | `if(changeDay === 1){``changeDay = 0;``socket.emit('change-day', "true");` | 当从传感器接收到新数据时,通过检查变量 changeDay 是否为 1 来查看这是否是新的一天。如果是,则将变量 changeDay 重置为 0,并发送一个 socket.emit 来让前端知道日期已经更改。 |

Update Main.js

需要更新 main.js 文件来处理新的日期数据。还有一个将日期添加到 web 页面的函数,打开清单 8-8 中的 main.js 文件,添加粗体代码。

(function(){
    var socket = io();
    var temperature = new DonutChart();
    temperature.setSensorDomain([-6,50]);
    temperature.setSvgDiv('#donut1');
    temperature.createChart('\u00B0'+"c", "temp");
    ...
    socket.on("initial-data", function(data){
        temperature.updateChart(data.temp.current);
        humidity.updateChart(data.humidity.current);
        light.updateChart(data.light.current);
        changeHighLow(data);
    });
    socket.on('data', function(data){
        temperature.updateChart(data.temp.current);
        humidity.updateChart(data.humidity.current);
        light.updateChart(data.light.current);
        changeHighLow(data);
    });  
    socket.on('change-day', function(data){
        changeDate();
    }) ;
    function changeHighLow(data){
        for (key in data) {
            if (data.hasOwnProperty(key)) {
                var className = key + "-high";
                document.getElementById(className).innerHTML = data[key].high;
                className = key + "-low";

                document.getElementById(className).innerHTML = data[key].low;
            }
        }
    }
    function changeDate(){
        var date = new Date();
        var displayDate = document.getElementById('date');
        displayDate.innerHTML = date.toDateString();
    }
    changeDate();
})();

代码解释

表 8-10

main.js update explained

| `changeHighLow(data);` | 调用一个函数,该函数将使用数据的最新最高值和最低值更新网页。 | | `socket.on('change-day', function(data){``changeDate();` | 当调用 id 为“change-day”的 socket.emit 时,这意味着现在是午夜,需要更新网页日期。调用 changeDate()函数来完成这项工作。 | | `function changeHighLow(data){}` | changeHighLow()函数将更新网页。它作为参数传递给数据对象;这个对象有数据的键和值。 | | `for (key in data) {}` | JavaScript 中的 for 函数将让您遍历对象中的每一项。 | | `var className = key + "-high";` `document.getElementById(className).innerHTML = data[key].high;` | 当接收到新数据时,关键字将是字符串“温度”、“湿度”或“光”这将与字符串“-high”连接在一起,构成保存传感器高值的 HTML 标记的类名。然后用新值更新该标签的 innerHTML。然后对低值进行同样的操作。 | | `function changeDate(){``var date = new Date();``var displayDate = document.getElementById('date');``displayDate.innerHTML = date.toDateString();` | 函数 changeDate()使用 JavaScript Date()对象。这使您可以访问许多可以应用于 Date()对象的 JavaScript 函数,包括 toDateString()方法。这会将日期写成字符串。将保存日期的标签的内部 HTML 用最新的日期更新。 |

表 8-10 解释 main.js 中的代码。

Update Index.ejs File

需要更新 index.ejs 文件以显示新数据。打开清单 8-9 中的 index.ejs 文件,添加粗体 HTML。

<!DOCTYPE html>
<head>
    ...
</head>
<body>
    <header>
        <h1>SENSOR DASHBOARD</h1>
        <h2>temperature humidity light</h2>
        <h3><time id="date"></time></h3>
    </header>
    <main>
        <h3>current values</h3>
        <div class="container">
            ...
        </div>
        <div class="container">
            <div id="temp" class="high-low">
                <div class="high">
                    <p>today's high</p>
                    <p><span id="temp-high">27</span>&deg;C</p>
                </div>
                <div class="low">
                    <p>today's low</p>
                    <p><span id="temp-low">27</span>&deg;C</p>

                </div>
            </div>
            <div id="humidity" class="high-low">
                <div class="high">
                    <p>today's high</p>
                    <p><span id="humidity-high">27</span>%</p>
                </div>
                <div class="low">
                    <p>today's low</p>
                    <p><span id="humidity-low">27</span>%</p>
                </div>
            </div>
            <div id="light" class="high-low">
                <div class="high">
                    <p>today's high</p>
                    <p><span id="light-high">27</span></p>
                </div>
                <div class="low">
                    <p>today's low</p>
                    <p><span id="light-low">27</span></p>
                </div>
            </div>
        </div>
    </main>
    ...
</body>
</html>

代码解释

表 8-11 解释了 index.ejs 中的代码。

表 8-11

index.ejs update explained

| `

` | HTML 有一个时间标签,机器阅读器使用它来正确地解释时间。 | | `27` | 跨度用于保存更新的数据。 | | `°` | 这会将度数符号放在页面上。 |

Update the CSS

打开清单 8-10 中的 main.css 文件,并将以下 css 添加到文件的底部。

.high-low{
    display: flex;
    flex-direction: row;
    justify-content: space-between;
    margin: 20px;
}
.high, .low{
  background-color: #FE8402;
  width: 120px;
  height: 120px;
  color: white;
  text-align: center;
  line-height: 2.5;
  display: inline-block;
  vertical-align: middle;
}
.low{
  background-color: #6BCAE2;
}

应用程序现在应该完成了;通过在控制台窗口中导航到应用程序并键入 nodemon index.js 或 node index.js 来启动应用程序,可以看到页面的外观。确保 Arduino 已连接到您的电脑,并打开浏览器,然后转到 http://localhost:3000/查看应用程序的运行情况。

摘要

您已经使用数据创建了一个仪表板。现在,您应该对如何获取原始数据、对其进行可视化并使用它来获得洞察力有了更好的理解。存储的数据可以用许多不同的方式进行分析和可视化。这一章应该只是你可以用仪表板做什么的起点。

九、实时数据的物理数据可视化

在第七章和第八章中,你使用 Arduino 向网络发送数据,这样数据就可以在网页上可视化。本章将扭转这一局面。您将从在线来源获取数据,并使用它来驱动压电蜂鸣器、LED 和连接到 Arduino 的 LCD。您将创建一个 Node.js 服务器,它将链接到一个外部网站并从该网站请求数据。这些数据将被清理并通过串行端口从 Node.js 服务器传送到 Arduino。该数据是来自美国地质调查局网站(USGS)的地震数据。美国地质调查局的地震数据定期更新。USGS 已经创建了一个 API,一种访问数据的方法,您可以使用它来请求您想要的数据。

应用程序接口

API 代表应用程序编程接口。这是您的应用程序与其他外部应用程序对话的方式。把它想象成一份餐馆菜单。菜单上列出了厨房为你准备的食物;你向服务员要一个项目,他们去厨房,要求该项目,并把它带回来给你。同样,您的服务器可以向外部服务器发出请求,如果您以正确的方式发出请求,您的请求将会得到满足。通过使用 API 的方法,您可以向外部 web 应用程序发送数据,也可以从外部 web 应用程序接收数据。

大多数有 API 的网站都会有一个页面,上面有如何使用的说明。它将列出可供您使用的方法以及这些方法所需的参数。

当你向一个 API 发出请求时,你就是在调用这个 API。可以限制对服务器进行 API 调用的次数。这样服务器就不会因为请求而过载。

许多 web 应用程序都有您可以使用的 API。Twitter 有一个 API,可以让你搜索和下载推文。微软有一个情感 API,让你给它发送一个人的图像,它将返回该图像的情感分数。

一个 API 可以返回许多不同类型的文档,这取决于它是如何设置的。您可能会收到不同格式的数据。返回给你的数据量是有限制的。应用程序 API 页面应该让您知道这个限制。

要访问一些服务器的 API,你需要注册该应用程序,并获得一个 API 密钥。每次您发出请求时都会用到这个密钥,它允许外部应用程序监控您的请求。

在这一章中,你将使用美国地质勘探局的 API 来获取关于地震的数据。你不需要注册服务或者使用 API 密匙;返回的查询限制为 20,000 个。

USGS API(USGS API)

美国地质调查局是一个研究美国地质的政府机构。他们的网站包括许多信息,包括水、火山和地震的信息。他们有许多 API,包括一个关于全球地震的 API。数据可以不同的数据格式返回,包括 CSV、XML 和 GeoJSON。该数据包含许多不同的字段,包括地震发生的时间、震级、经纬度坐标以及地震发生地点的名称。

对 API 的请求是一个 URL,在其中传递一个方法;您希望执行什么操作;和参数,要求数据的特定元素的键/值对。这些参数可以返回特定时间段、位置和震级的数据。

如果 URL 参数中没有为 time 指定时区,则假定它是 UTC。例如,字符串 2017-12-26T12:47:47 可以是隐式的 UTC 时区,而 2017-12-26T12:47:47 +00:00 将是显式的。图 9-1 显示了一个 USGS 请求 URL 的例子。

A453258_1_En_9_Fig1_HTML.jpg

图 9-1

An API call URL to the USGS server

URL 有一个到服务器的路径、一个方法和参数。USGS API 有许多可用的方法,包括查询方法,这是一种数据请求。还指定了想要返回的数据类型,在本例中为 GeoJSON。还有一些参数可以用来询问数据的特定部分。每个参数都是一个键值对,用“&”字符分隔每个参数。你可以在美国地质勘探局网站上的 API 文档页面上看到完整的方法和参数列表,网址为seismic . USGS . gov/FDS NWS/event/1/

从外部服务器获取数据

在本章中,您将向外部服务器请求数据,当您获得这些数据时,您将获取您想要的部件,并通过串行端口将这些数据发送到 Arduino。要从外部服务器获取数据,您需要从 Node.js 服务器向 USGS 服务器发出一个客户端请求。你的服务器叫做客户端。为此,您将使用 HTTP GET 请求。HTTP 在第二章“什么是 Web 服务器”一节中讨论过

您可以使用 Node.js 附带的 http 库来发出 HTTP 请求,但是很多应用程序使用第三方库来简化请求的实现。在本章中,您将使用一个名为 axios 的库来发出 HTTP 请求。axios 的优势之一是它使用承诺,而 Node.js 本地 HTTP 请求使用回调。

回访和承诺

代码可以是同步的,也可以是异步的。同步代码一行接一行运行,因此代码

console.log("Tuesday");
console.log("Wednesday");

将打印出字符串“星期二”,然后是“星期三”,代码会在第二个控制台日志执行之前等待第一个控制台日志执行。

异步代码将开始运行,但它之后的代码不会等到运行完成后再运行。HTTP 请求是异步的;这意味着当向外部服务器发出请求时,您的代码将继续运行;它不会等待外部服务器的响应。这样做的好处是,您的应用程序在等待外部服务器的响应时会继续工作。这也意味着请求之后的函数将不能访问响应数据,因此您不能保证在其他函数运行之前数据已经返回。

您将在其他函数中需要来自 HTTP 请求的响应数据,因此您需要一种方法让需要数据的其他函数等待,直到 HTTP 请求返回数据。回电或承诺是做到这一点的一种方式。

回调函数

回调函数是作为参数传递给函数的函数。这意味着可以向第二个函数传递第一个函数获得的数据。图 9-2 显示了一个简单回调函数的例子。打印数字的方法作为参数传递给创建数字的函数。

A453258_1_En_9_Fig2_HTML.jpg

图 9-2

An example of a synchronous callback function

这是一个简单的回调函数的同步例子。它们对于异步函数非常有用,因为作为参数传递的函数只有在异步函数完成某些事情时才会被调用。当你调用外部服务器时,你需要等待它返回一些东西给你,然后才能运行一个函数;这可以通过回调函数来完成。图 9-3 显示了一个向外部服务器发出请求的异步回调的伪代码示例。伪代码是一种不使用特定编程语言来解释代码如何工作的方法;它不会作为一段代码运行。

A453258_1_En_9_Fig3_HTML.jpg

图 9-3

Pseudocode of an asynchronous callback

使用回调的一个缺点是你可能以嵌套的回调结束;如果您的应用程序需要来自一个服务器的数据,然后又需要来自另一个服务器的数据,那么这些调用将相互嵌套;这可能会变得混乱,很难搞清楚什么是所谓的什么。

承诺

承诺是回调的一种替代方法。它是在发出请求之前创建的对象。这是一个承诺,某事会发生,可能是成功或失败。这是一个承诺,将有一个函数可以理解的值。它意味着承诺立即返回一个值,就像同步函数一样。这个值是一个承诺,它将在未来返回一个值,这个值可以是一个成功对象,也可以是一个失败对象。承诺使异步代码扁平化,从而减少嵌套回调的数量。

在本章中,您将使用一个名为 axios 的库向外部服务器发出请求。这是一个基于承诺的图书馆。

请求响应状态代码

当您向服务器发出 HTTP 请求时,调用函数将收到来自该服务器的响应代码。这可以用来检查响应是否成功,如果不成功,为什么不成功。响应分为 100、200、300、400 和 500 个数字类别:

  • 100-信息回复,它们让你知道你的请求已经被接受和理解;例如,102 是处理的响应。
  • 200 秒–成功响应,您的请求已被成功接收和处理:例如,200 秒表示 HTTP 请求已被成功处理。
  • 300 秒–重定向响应。
  • 400s 客户端错误响应,当调用服务器的客户端在请求中出错时:例如,402 payment required。
  • 500s 服务器错误,当服务器出现错误时:例如,503 服务不可用;如果您呼叫的服务器当前没有运行,则使用此选项。

节点。JS 应用程序

本章中的应用程序将联系 USGS 服务器以请求数据。如果有新的数据,它将被处理并重新格式化,以便发送到 Arduino。

对 USGS 服务器的 API 请求是一个 URL,它告诉 USGS 服务器您想要取回的数据。在本章中,您将每 15 分钟请求一次数据。你的服务器将会询问 USGS 服务器自从你的服务器最后一次询问以来是否有地震发生。这将通过 URL 末尾的查询字符串来完成。查询字符串包含您希望返回的数据格式、查询的开始时间、查询的结束时间、您感兴趣的地震震级以及 USGS 服务器将向您发送的响应数量限制。

如果查看 USGS API,您会发现数据和时间的格式必须是 ISO8601 日期/时间格式。这种格式是共享时间的国际标准。JavaScript 具有将日期对象转换成 ISO8601 格式的函数。

axios 请求位于一个名为 makeCall()的函数中。使用 setTimeout()函数每隔 15 分钟调用一次 makeCall()函数。在调用该函数之前,会创建一个带有开始时间的变量。你可以设置任何时间,但是在你开始运行应用程序之前 2 个小时应该足够确保你得到地震数据返回。第一次使用 startTime 变量,调用 makeCall()作为 GET 请求的开始时间。使用 JavaScript new Date()函数创建结束时间。Date()函数从您的服务器(即您的计算机)获取日期和时间。

然后发出 axios GET 请求,并从 USGS 服务器返回 GeoJSON 对象。这个对象将包含许多关于最近地震的信息,包括震级、经纬度坐标和警报类型。只有部分数据会被传送到 Arduino,部分原因是 LCD 屏幕只能处理 64 个字符。从 GeoJSON 中检索相关数据,并创建一个可以传递给 Arduino 的字符串。

startTime 变量然后取 endTime 变量的值;该函数等待 15 分钟后再次被调用。从现在开始,开始时间和结束时间将相差 15 分钟。

Note

JavaScript new Date 函数用当前时间创建一个对象。这个当前时间是从您的计算机时钟中获取的。如果你电脑上的时间不正确,你将得不到最新的地震数据。您可能还想在 API 请求中显式设置 UTC。

setTimeout 与 setInterval

JavaScript 中有两个调度函数:setTimeout 和 setInterval。两者都会在一定数量的毫秒后开始运行。图 9-4 显示了它们是如何工作的。

A453258_1_En_9_Fig4_HTML.png

图 9-4

A setTimeout() and a setInterval() function

图 9-4 中的 setTimeout()和 setInterval()都会在 1000 毫秒(1 秒)后调用 makeCall()函数。setTimeout()函数将调用它一次,setInterval()将每秒调用一次。

本章中的 Node.js 应用程序使用 setTimout 函数来调用 makeCall()函数,尽管它必须每隔 15 分钟调用一次。这是因为 setInterval 将重复调用它的函数,即使该函数从最后一次调用起还没有结束运行。setTimeout()函数让它调用的函数完全运行。它只运行一次,所以在它调用的函数结束时必须再次调用 setTimeout()。

GeoJSON 对象

USGS 服务器可以返回许多不同的数据类型,GeoJSON 就是其中之一。它的结构与 JSON 相似,是地理数据的标准。从 USGS 返回的 GeoJSON 有许多字段,包括状态代码、标题和要解析的数据。您可以像解析 JSON 对象一样解析数据。在 Node.js 服务器中,您将使用一行代码“var data = response . data . features;”它使用点符号深入到 GeoJSON 中以获得特征,这是一个要发送到 Arduino 的数据数组。图 9-5 显示了从 USGS 返回的 GeoJSON 特征的示例。

A453258_1_En_9_Fig5_HTML.png

图 9-5

An example of the GeoJSON from a GET request to the USGS server Set Up the Node.js Server

在本章中,您将从 Node.js 服务器向 Arduino 发送数据,因此您不需要应用程序的 web 前端。应用程序的目录结构如下:

/chapter_09
    /node_modules
    index.js

因为没有应用程序的前端,所以您不需要安装 express、ejs 或 socket.io。该库将向 USGS 服务器发出 HTTP 请求:

  1. 创建一个新文件夹来存放应用程序。我叫我的章 _09。
  2. 打开命令提示符(Windows 操作系统)或终端窗口(Mac)并导航到新创建的文件夹。
  3. 当你在正确的目录键入 npm init 创建一个新的应用程序;您可以按下 return 键浏览每个问题或对其进行更改。
  4. 下载完成后,安装串口。在 Mac 类型 npm 上安装 serial port @ 4 . 0 . 7-save;在 Windows PC 上,键入 NPM install serial port @ 4 . 0 . 7-build-from-source。
  5. 下载 axios 库;在命令行键入 npm 安装 axios@0.17.1 - save。

在 chapter_09 应用程序的根目录下打开或创建一个 index.js 文件,并复制清单 9-1 中的代码。

var http = require('http');
var axios = require('axios');

var startTime = '2017-12-26T12:47:47'
var makeCall = function(){
    var endTime = new Date();
    endTime = endTime.toISOString();
    endTime = endTime.split('.')[0];
    var url =    
  'https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson&starttime=' + startTime + '&endtime=' + endTime + '&minmagnitude=4&limit=1';
    var request = axios({
        method:'get',
        url:url,
        responseType:'json'
    });

    request.then(function(response) {
        console.log(response);
        var data = response.data.features;
        console.log(data);

        if(data.length > 0){
            var date = new Date(data[0].properties.time);

            var formatDay = (date.getMonth() + 1) + '/' + date.getDate() + '/' + date.getFullYear().toString().substr(2,2);
            var formatClock = date.getHours() + ":" + date.getMinutes();

            var quakeString = data[0].properties.mag + " "
            + formatDay + " " + formatClock + " " + data[0].properties.place;
            startTime = endTime;
        }
    })
      .catch(function(error){
        console.log('request error: ' + error);
    });
    setTimeout(makeCall, 600000);
}
makeCall();

Listing 9-1index.js

在控制台中转到应用程序的根目录,键入 nodemon index.js 或 node index.js 来启动服务器。您应该开始在控制台窗口中看到来自 console.log 函数的数据。

代码解释

表 9-1 解释了 index.js 中的代码。

表 9-1

index.js explained

| `var axios = require('axios');` | 将 axios 库包含到 Node.js 服务器中。 | | `var startTime = '2017-12-26T12:47:47'` | 创建一个变量,以 ISO8601 日期/时间格式保存请求的开始时间。您应该在接近启动服务器的时间重置它。要指定 UTC,请将字符串“+00:00”添加到 startTime 字符串的末尾。 | | `var endTime = new Date();` `endTime = endTime.toISOString();` | 变量 endTime 保存您希望 USGS 服务器请求结束的时间和日期,并使用 JavaScript toISOString()函数将其转换为 ISO 字符串。要指定 UTC,请将字符串“+00:00”添加到 endTime 字符串的末尾。 | | `endTime = endTime.split('.')[0];` | 函数的作用是:返回一个在“.”后包含时区的字符串;USGS URL 不理解这一点,因此使用 JavaScript split()函数将它和其后的数据一起删除,在“.”上进行拆分 | | `var url =``'`[`https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson&starttime`??`=' + startTime + '&endtime=' + endTime + '&minmagnitude=4&limit=1';`](https://earthquake.usgs.gov/fdsnws/event/1/query%3Fformat=geojson%26starttime) | 变量 url 包含将作为 HTTP GET 请求发送到 USGS 服务器的 URL。它包含格式、请求的开始和结束时间,以及返回一个地震的限制。它还包含 minmagnitude 参数,该参数返回大于指定最小值的震级。 | | `var request = axios({``method:'get',``url:url,``responseType:'json'` | 变量 request 保存 axios HTTP 请求对象,该对象指定了所使用的 HTTP 方法:在本例中是 GET,它将调用的 URL 和它期望作为响应的数据类型。 | | `request.then(function(response) {` | 请求是一种承诺。当请求对象返回数据时,将调用一个函数,并将响应数据作为其参数。 | | `console.log(response);` | 值得在控制台中查看一下从 USGS 返回的响应数据。您将深入研究响应数据,以找到关于地震的数据。 | | `var data = response.data.features;` `console.log(data);` | 你用点符号解析 GeoJSON,这样就可以下钻到你想要的数据;如果您查看控制台中的响应,您会看到您想要的数据在数据、要素中。如果您控制台记录这些数据,您会看到它是一个数组,里面有一个对象。 | | `if(data.length > 0){` | 检查在你呼叫 USGS 的时间段内是否发生过地震。如果没有,那么数组数据的长度将是 0,你不需要更新 Arduino。如果它大于零,则运行 If 语句中的代码。 | | `var date = new Date(data[0].properties.time);` | 地震的日期和时间存储在变量数据中。data[0]用作数组中数据的对象。因为数组中只有一个元素,所以获取它的总是 data[0]。然后,您可以深入到属性数据,然后是时间数据, | | `var formatDay = (date.getMonth() + 1) + '/' + date.getDate() + '/' + date.getFullYear().toString().substr(2,2);` | 变量 formatDay 保存重新格式化的日期;它将以月/日/年的形式出现。substr(2,2)将四位数的年份字符串更改为两位数的年份字符串。 | | `var quakeString = data[0].properties.mag + " "` `+ formatDay + " " +            formatClock + " " + data[0].properties.place;` | 变量 quakeString 将是发送到 Arduino 的字符串,它包括地震的震级、数据、时间和地点。 | | `startTime = endTime;` | 变量 startTime 采用 endTime 的值,因此下一次循环将在 endTime 之前 15 分钟。 | | `.catch(function(error){``console.log('request error: ' + error);` | catch 函数是 axios 库的一部分,它捕捉来自外部服务器的任何错误,您可以决定下一步做什么。 | | `setTimeout(makeCall, 600000);` | 一旦 makeCall 函数运行,setTimeout 函数将被调用,以便在 600000 毫秒(15 分钟)后再次调用它。 | | `makeCall();` | 调用 makeCall()函数。 |

Arduino 组件

该应用将使用 LED、LCD 和压电蜂鸣器;将首先设置 LED 和压电。

压电蜂鸣器

压电蜂鸣器产生声音。它包含一种压电材料。压电材料在通电时会改变形状,从而产生声音。材料弯曲得越快,频率越高,声音也越大。Arduino 有一个称为 tone 的功能,它控制压电;它有两个论点。第一个参数是压电元件所附着的管脚号,第二个参数是压电元件的频率。

Set Up the Led And Piezo

首先,您只需设置压电和 LED,并让它们工作。图 9-6 显示了您将需要的组件:

A453258_1_En_9_Fig6_HTML.jpg

图 9-6

The Arduino Components: 1. Breadboard, 2. LED, 3. Piezo, 4 220 ohm resistor, 5. Arduino Uno

  • 220 欧姆电阻器
  • 1 个 LED
  • 220 欧姆电阻器
  • 1 压电

如图 9-7 所示设置 Arduino 和组件。

A453258_1_En_9_Fig7_HTML.jpg

图 9-7

Setup for the Arduino with Peizo and LED

打开 Arduino IDE,创建一个名为 chapter_09_01.ino 的新草图,并复制清单 9-2 中的代码。

const int buzzer = 9;
const int led = 13;
int state = LOW;
boolean piezoState = false;

void setup(){
  pinMode(buzzer, OUTPUT);
  pinMode(led, OUTPUT);
}
void loop(){
  blink_led();
  digitalWrite(led, state);  
  buzz();

  if(piezoState){
    tone(buzzer, 500);
  }else{
    noTone(buzzer);
  }
  delay(500);
}
void blink_led()
{
  state = !state;
}
void buzz(){
   piezoState = !piezoState;
}

Listing 9-2chapter_09_01.ino

验证代码,然后通过 USB 将 Arduino 连接到端口,将草图上传到 Arduino。确保您在工具菜单中为 Arduino 选择了正确的端口:工具/端口。灯和压电蜂鸣器应每 500 毫秒开关一次。

代码解释

表 9-2 解释了 chapter_09_01.ino 中的代码。

表 9-2

chapter_09_01.ino explained

| `const int buzzer = 9;` `const int led = 13;` | 为压电和 LED 的引脚编号创建常量变量。 | | `int state = LOW;` `boolean piezoState = false;` | 初始状态存储在压电和 LED 的变量中。 | | `pinMode(buzzer, OUTPUT);` `pinMode(led, OUTPUT);` | 压电和 LED 都设置了引脚编号,并设置为输出。 | | `blink_led();` | 调用一个名为 blink_led 的函数,这会将 led 的状态切换为高和低。 | | `void blink_led()``{``state = !state;` | 函数 blink_led 没有返回值,所以它被声明为 void。它使状态变为非状态,高或低(开或关)。 | | `digitalWrite(led, state);` | 告诉 LED 它处于什么状态。 | | `buzz();` | 调用一个名为 buzz 的函数,该函数将计算出压电元件的当前状态,嗡嗡声或不嗡嗡声,然后进行相反的操作。 | | `void buzz(){``piezoState = !piezoState;` | 函数 buzz 没有返回值,所以它被声明为 void。它使压电状态变成它不是的状态,不是真就是假。 | | `if(piezoState){``tone(buzzer, 500);``}else{``noTone(buzzer);` | 检查压电状态变量是否包含值 true 如果是,则调用函数 tone()。它需要两个参数,压电元件的引脚数和蜂鸣器的频率。 |

Update the Node.js Server

需要更新 Node.js 服务器以包含 serialport 库,并将数据发送到 Arduino。打开清单 9-1 中的代码,用清单 9-3 中的粗体代码更新它。

var http = require('http');
var axios = require('axios');
var SerialPort = require('serialport');

var serialport = new SerialPort('<add in the serial port for your Arduino>', {

    baudRate: 9600

});

serialport.on("open", function () {

    console.log('open');
    makeCall();

});

var startTime = '2017-12-26T12:47:47'

var makeCall = function(){

    var endTime = new Date();
      endTime = endTime.toISOString();
      endTime = endTime.split('.')[0];

    var url = 'https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson&starttime=' + startTime + '&endtime=' + endTime + '&minmagnitude=4&limit=1';

    var request = axios({
        method:'get',
        url:url,
        responseType:'json'
    });

    request.then(function(response) {
        var data = response.data.features;
        console.log(data);

        if(data.length > 0){
            var date = new Date(data[0].properties.time);
                var formatDay = (date.getMonth() + 1) + '/' + date.getDate() + '/' +  date.getFullYear().toString().substr(2,2);
            var formatClock = date.getHours() + ":" + date.getMinutes();

            var quakeString = data[0].properties.mag + " "
            + formatDay + " " + formatClock + " " + data[0].properties.place;

            console.log(quakeString);

            setTimeout(function() {
                serialport.write(quakeString, function() {
                    console.log('written to serialport');
                });
            }, 2000);

            startTime = endTime;
        }
    })
      .catch(function(error){
        console.log('request error: ' + error);
    });
    setTimeout(makeCall, 600000);
}

Listing 9-3updated index.js

删除 Arduino >的端口,并在新的 serial port()函数中添加自己的串口。请注意,makeCall()函数调用已从脚本底部移除,并添加到 serialport.on()函数中。

代码解释

表 9-3 解释了更新后的 index.js 文件中的代码。

表 9-3

updated index.js explained

| `serialport.on("open", function () {``console.log('open');``makeCall();` | 一旦串行端口被打开,makeCall()函数被调用,您不希望在串行端口打开之前调用它,因为 makeCall()函数通过串行端口传递数据。 | | `setTimeout(function() {` `}, 2000);` | 该 setTimeout 函数在将数据发送到 Arduino 之前提供两秒钟的延迟;这是为了确保串行端口确实准备好接收数据。 | | `serialport.write(quakeString, function() {``console.log('written to serialport');` | serialport.write()函数将包含要发送到 Arduino 的数据的 quakeString 发送到串行端口,一旦接收到数据,它会在控制台记录数据已被写入 serialport。 |

Add An LCD To The Arduino

当 Arduino 接收到新的地震数据时,LCD 将开始显示数据,LED 将闪烁,蜂鸣器将蜂鸣。几秒钟后,闪烁和嗡嗡声将停止。LCD 的设置与第五章中的设置相同。这也意味着 LCD 的字符限制为 64 个字符。对于传递给它的大多数数据来说,这应该没问题,但是较长的字符串会被截断。部件如图 9-8 所示,具体如下:

A453258_1_En_9_Fig8_HTML.jpg

图 9-8

The components for the LCD: 1. Breadboard, 2. potentiometer, 3. 220 ohm resistor, 4. Arduino Uno, 5. LCD

  • 220 欧姆电阻器
  • 电位计
  • 液晶显示

您需要设置 LCD,使其与压电和 LED 一起工作。我用了两个小试验板。

按照图 9-9 中的设置将 LCD、压电和 LED 连接到 Arduino。

A453258_1_En_9_Fig9_HTML.jpg

图 9-9

Setup for the LCD, Piezo, and LED

打开一个新草图,将其命名为 chapter_09_02,并复制清单 9-4 中的代码。

#include <LiquidCrystal.h>
const int rs = 12, en = 11, d4 = 5, d5 = 4, d6 = 3, d7 = 2;
LiquidCrystal lcd(rs, en, d4, d5, d6, d7);

const int buzzer = 9;
const int led = 13;

int state = LOW;
boolean piezoState = false;

int newData = 14;

void setup() {
  lcd.begin(16, 1);
  pinMode(buzzer, OUTPUT);
  pinMode(led, OUTPUT);
  Serial.begin(9600);
}

void loop() {
  if(Serial.available()){
    newData = 0;
    lcd.home();
    while(Serial.available() > 0){
      lcd.write(Serial.read());
    }
  }
  lcd.scrollDisplayLeft();
  if(newData < 12){
    newData = newData + 1;
    blink_led();
    digitalWrite(led, state);
    buzz();
    if(piezoState){
      tone(buzzer, 500);
    }else{
      noTone(buzzer);
    }
  }
  delay(500);
}

void blink_led(){
  state = !state;
}

void buzz(){
   piezoState = !piezoState;

}

Listing 9-4chapter_09_02.ino

验证脚本,然后将其上传到 Arduino。确保 Node.js 应用程序已关闭。如果它仍在运行,代码将不会上传到 Arduino,因为 Node.js 应用程序已经使用了串行端口。

LCD 的设置与第五章相同,LED 和压电的引脚和状态变量与列表 9-2 相同。

代码解释

表 9-4 解释了章节 _09_02.ino 中的代码。

表 9-4

chapter_09_02.ino explained

| `int newData = 14;` | 变量 newData 包含一个将停止压电蜂鸣和 LED 闪烁的值。 | | `lcd.begin(16, 1);` | 函数的作用是:设置 lcd 使用的行数和列数。对于这个应用程序,您需要 1 行 16 列。 | | `if(Serial.available()){` | 检查数据是否来自串行端口。 | | `newData = 0;` | 如果有新数据到来,将 new data 变量设置为 0,这样 LED 和蜂鸣器将被激活。 | | `lcd.home();` | 将 LCD 光标放在屏幕的左上角。 | | `while(Serial.available() > 0){``lcd.write(Serial.read());` | 当有串行数据通过时,读取串行数据并将其写入 LCD。 |

当草图上传到 Arduino 时,确保 Arduino 仍然通过 USB 连接到您的计算机。在控制台中,导航至章节 9 应用程序。键入 node index.js 或 nodemon index.js 来启动应用程序。对 USGS 服务器的初始调用应该返回地震数据,因此压电应该发出蜂鸣声,LED 应该闪烁,并且文本应该出现在 LCD 上。请记住,您可能需要转动电位计才能看到液晶屏上的文本。

摘要

本章使用 Node.js 服务器从另一个服务器获取数据,而不是提供自己的网页。这使您能够从许多不同的来源获取数据,以驱动 Arduino 上的组件。你可以通过为不同震级的地震添加不同的 LED 来扩展第九章的项目,或者改变压电发出的声音。

下一章将介绍浏览器中的 3D,以及我们如何使用 Arduino 组件操作 3D 对象。

十、创建游戏控制器

有了动画,你可以创造任何你能想到的东西:真实的或超现实的,简单的或复杂的。它可以讲述一个故事,也可以是完全抽象的,或者介于两者之间。当 HTML5 发布时,它包含了一个可用于 2D 和 3D 动画的 canvas 元素。

在本章中,您将创建一个游戏,游戏杆连接到 Arduino,Arduino 用作游戏控制器。本章中的 3D 图形是使用名为 Three.js 的 JavaScript 库创建和操作的,来自 Arduino 的数据用于操作图形。

动画

动画的流畅取决于两件事:每秒帧数(FPS),动画运行的帧速率;以及一帧和下一帧有多么不同。

当你为网页浏览器制作动画时,你不能保证它的帧率。每一帧都必须由观众的电脑渲染,所以这将取决于他们的电脑渲染一帧的速度。场景越复杂,计算机处理器要做的工作就越多,因此这会降低帧速率。

HTML5 画布元素

当 HTML5 在 2014 年发布时,它支持新的多媒体和图形格式,而以前的 HTML 版本不支持这些格式。它包括新的元素,如视频、音频和画布。

canvas 元素在网页上为图形元素创建一个区域。它与其他 HTML 元素具有相同的结构,并且像其他元素一样可以具有宽度和高度等属性。创建一个画布元素。

它在 DOM 中,可以由 JavaScript 选择,并用于显示和动画脚本形状和场景。开发了许多 JavaScript 库,使得在画布上创建动画变得更加容易;其中包括 processing.js、PixiJS、Paper.js、BabylonJS 以及本章中使用的 Three.js。

CSS 动画

用 JavaScript 制作动画还有一个替代方案,那就是用 CSS 制作动画。它的优点在于它使用较少的处理能力。在网页上制作 2D 动画已经成为一种流行的方式,并且已经发布了许多 CSS 动画框架,使得制作 CSS 动画变得更加容易。其中包括 Animate.css、Animatic 和 Loader CSS。

网络上的 3D

现代网络浏览器上的三维图形使用 WebGL。它允许您创建可以在 HTML canvas 元素中显示的动画。

web GL(web GL)

WebGL (Web Graphics Library)是一个 JavaScript API,允许您在 Web 浏览器中创建和动画 3D 对象。它基于 OpenGL,可以在大多数现代网络浏览器上运行。它使用计算机的图形处理单元(GPU)来处理 3D 场景,而不是浏览器。WebGL 使用两种编程语言来处理和渲染场景,JavaScript 和 GLSL (OpenGL 着色语言)。GLSL 翻译 JavaScript,这样它就可以通过 GPU 运行代码。有两个着色器程序通过 GPU 运行,一个顶点着色器和一个片段着色器。您需要实现这两者来渲染场景。

WebGL 中的编码会变得相当复杂;它没有渲染器,所以你必须编写所有的着色功能。在 WebGL 之上已经构建了许多 JavaScript 库;为了更容易使用,其中一个是 Three.js。

三维空间

三维图形使用三维来创建一个世界。对象(网格)存在于 3D 世界中,并与世界中的其他对象进行动画制作和交互。3D 场景可以由网格、灯光、相机以及颜色和纹理组成。

有三个轴,每个维度一个:x 轴、y 轴和 z 轴。在 Three.js 中,x 轴是水平轴,y 轴是垂直轴,z 轴是深度。图 10-1 显示了 WebGL 和 Three.js 中的 3D 轴。

A453258_1_En_10_Fig1_HTML.jpg

图 10-1

The x-, y-, and z-axes

Three.js 场景中的坐标系不同于浏览器的坐标系。浏览器的坐标系统通常由一个称为像素的单位组成。它从浏览器窗口左上角的 0,0 开始。位置 1,1 将向右 1 个像素,向下 1 个像素。WebGL 坐标系从 WebGL 场景中心的 0,0 开始。WebGL 中的 1 单位不是 1 像素。所以移动一个 WebGL 对象 1,1 会使它移动超过 1 个像素。

该浏览器也是二维的,因此 3D 场景需要转换以适应 2D 画布。

3D 网格

三维(3D)对象由 3D 空间中的顶点(点)组成,由边连接,形成面。对象存在于具有 x、y 和 z 坐标的 3D 空间中。图 10-2 显示了三维物体的点、线和面。

A453258_1_En_10_Fig2_HTML.jpg

图 10-2

A 3D object

着色器

着色器计算如何在 3D 空间中渲染对象。着色器在对象设置动画时计算出对象的位置和颜色。他们还计算出物体的每个部分相对于其位置和场景中其他元素(如照明)的明暗程度。WebGL 使用 GPU 的顶点着色器和片段着色器来渲染场景。

顶点明暗器

3D 中的对象由顶点组成。顶点着色器处理这些顶点中的每一个,并在屏幕上给它一个位置。它将顶点的三维位置转换成屏幕上的 2D 点。

片段着色器

片段着色器在渲染对象时计算出对象的颜色。可以有许多元素决定对象的颜色,包括其材质颜色和场景中的照明。WebGL 通过在三个顶点之间创建三角形来构成对象的面。片段着色器计算出每个顶点的值,并在顶点处插值以计算出整体颜色。渲染所涉及的基本步骤如图 10-3 所示。

A453258_1_En_10_Fig3_HTML.png

图 10-3

The rendering process

顶点数组被发送到 GPU 顶点着色器函数,经过处理后,它们被组装成三角形并被光栅化。光栅化将表示为矢量的三角形转换为像素表示。这些然后通过 GPU 上的片段着色器函数发送,并且一旦被处理,就被发送到准备在网页上渲染的帧缓冲区。

摄像机和灯

相机和灯光可以添加到空间中。Three.js 提供了许多不同的相机,包括正交相机和透视相机。

在 Three.js 中有不同类型的灯光。它们是环境光,平行光,点光和聚光灯。环境光将均匀地照亮整个场景。它的位置、旋转和比例没有影响,但它的颜色和强度有影响。平行光类似于太阳;他们有一个方向,但却无限遥远。这意味着与物体的距离无关紧要,但位置和旋转有关系。点光源类似于灯泡;它们从各个方向照亮空间,它们的位置很重要。聚光灯与点光源相似,因为它们的位置很重要,但它们只在一个方向上发光。

三. js

Three.js 是构建在 WebGL 之上的众多 JavaScript 库之一。它的功能可以让您在几个步骤中创建一个场景,并添加着色器,灯光,相机和网格。

三个向量

Three.js Vector3 是一个表示具有三个元素的向量的类,用于表示 x 轴、y 轴和 z 轴。Vector3 代表的主要东西是 3D 空间中的一个点,3D 空间中的方向和长度,或者是一个仲裁有序的三个数字。在本章中,Vector3 用于摄像机设置。

Create a Three.js Scene

在 Three.js 中查看任何内容所需的基本组件是场景、摄像机和渲染器。3D 对象和灯光等元素附加到场景对象。场景对象和相机对象被附加到渲染器对象以便被渲染。

创建一个名为 basic_scene.html 的文件,并将清单 10-1 中的代码复制到其中。

<html>
    <head>
        <title>three.js </title>
        <style>
            body { margin: 0; }
            canvas { width: 100%; height: 100% }
        </style>
    </head>
    <body>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r71/three.js"></script>
<script>
        var scene = new THREE.Scene();
        var camera = new THREE.PerspectiveCamera( 75, window.innerWidth/window.innerHeight, 0.1, 1000 );
        var renderer = new THREE.WebGLRenderer();
        renderer.setSize( window.innerWidth, window.innerHeight );
        document.body.appendChild( renderer.domElement );
        var geometry = new THREE.BoxGeometry( 1, 1, 1 );
        var material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
        var cube = new THREE.Mesh( geometry, material );
        scene.add( cube );
        cube.rotation.y = 40;
        camera.position.z = 5;

        renderer.render(scene, camera);
    </script>
    </body>
</html>

Listing 10-1Basic_scene.html

在 web 浏览器中打开该文件,您应该会在浏览器中看到一个绿色立方体。清单 10-1 中的 Three.js 代码首先创建一个三场景,然后是一个摄像机,然后是一个渲染器。渲染器附加到 HTML 的主体。创建一个立方体并添加到场景中。立方体有位置和旋转。渲染器在代码结尾被调用,场景和摄像机被附加。

代码 ExplainedTable 10-1 在 basic_scene.html 中有更详细的代码。

表 10-1

basic_scene.html explained

| `var scene = new THREE.Scene();` | Three.js 函数三。Scene()用于创建一个新的场景对象,该对象存储在变量 scene 中。 | | `var camera = new THREE.PerspectiveCamera( 75, window.innerWidth/window.innerHeight, 0.1, 1000 );` | 一个新的照相机对象被存储在可变照相机中。是一台 Three.js 透视相机。0.1 是视野,1000 是长宽比。 | | `var renderer = new THREE.WebGLRenderer();` | Three.js 渲染器对象存储在变量渲染器中。 | | `renderer.setSize( window.innerWidth, window.innerHeight );` | setSize 函数将调整画布的大小;在这个例子中,画布的大小是浏览器窗口的大小,所以它使用浏览器窗口的当前宽度和高度。 | | `document.body.appendChild( renderer.domElement );` | 渲染器被附加到一个 DOM 元素上,因此可以放在网页上。 | | `var geometry = new THREE.BoxGeometry( 1, 1, 1 );` | Three.js 有很多几何对象;这些对象包含对象的点(顶点)和填充(面)。 | | `var``material` | MeshBasicMaterial 是 Three.js 中可用的材质之一。它们都接受一个 properties 对象,这个对象使用十六进制数字被赋予绿色。十六进制数前面的 0x 告诉 JavaScript 后面的数字是十六进制数。这是 JavaScript 和其他编程语言中使用的语法。 | | `var cube = new THREE.Mesh( geometry, material );` | Three.js 网格对象将材质添加到几何体中。 | | `scene.add( cube );` | 立方体被添加到场景中。 | | `cube.rotation.y = 40;` `camera.position.z = 5;` | 立方体在 y 轴上旋转,并在 z 轴上给定一个位置。 | | `renderer.render(scene, camera);` | 渲染器在网页上渲染,场景和相机是渲染函数的参数。 |

Animating the Cube

此刻立方体是静止的。需要有一个动画循环来创建动画。使用 JavaScript requestAnimationFrame(回调)函数。当您希望浏览器重新绘制网页时,会调用该函数。回调函数被传递给函数,它通常以每秒 60 帧的速率运行。打开清单 10-1 中的 basic_scene.html,用清单 10-2 中的粗体代码更新它。

<html>
    <head>
        <title>three.js basics</title>
        <style>
            body { margin: 0; }
            canvas { width: 100%; height: 100% }
        </style>
    </head>
    <body>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r71/three.js"></script>
    <script>
        var scene = new THREE.Scene();
        var camera = new THREE.PerspectiveCamera( 75, window.innerWidth/window.innerHeight, 0.1, 1000 );
        var renderer = new THREE.WebGLRenderer();
        renderer.setSize( window.innerWidth, window.innerHeight );
        document.body.appendChild( renderer.domElement );
        var geometry = new THREE.BoxGeometry( 1, 1, 1 );
        var material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
        var cube = new THREE.Mesh( geometry, material );
        scene.add( cube );
        cube.rotation.y = 40;
        camera.position.z = 5;
        var animate = function () {
            cube.rotation.x += 0.05;
            cube.rotation.y += 0.01;
            cube.rotation.z += 0.007;
            renderer.render(scene, camera);
            requestAnimationFrame( animate );
        };
        animate();
    </script>
    </body>
</html>
Listing 10-2basic_scene.html updated

请注意,渲染器现在位于 animate 函数内部。在 web 浏览器中重新加载页面;你现在应该看到立方体动画。它在 x 轴、y 轴和 z 轴上旋转,每个轴的速率略有不同。

代码解释

表 10-2 更详细的解释了 basic_scene.html 中的代码。

表 10-2

basic_scene.html updated explained

| `var animate = function () {}` | 创建一个函数,其中包含动画场景对象的代码。 | | `cube.rotation.x += 0.05;``cube.rotation.y += 0.01;` | 立方体的旋转在 x 轴、y 轴和 z 轴上略有变化。 | | `renderer.render(scene, camera);` | 场景和相机被渲染到浏览器。 | | `requestAnimationFrame( animate );` | 一旦对多维数据集进行了更改,就会调用 JavaScript requestAnimationFrame()函数。animate 函数作为回调函数传递,因此它将继续运行 animate 函数。 |

Table

游戏

您在本章中创建的游戏将使用 Three.js 作为浏览器,并使用 Node.js 服务器通过串行端口从 Arduino 获取数据。这是一个简单的游戏,玩家试图抓住一个球拍上的球。附在 Arduino 上的操纵杆控制着船桨。操纵杆离中心位置越远,叶片在 x 轴上的移动速度就越快。球可以是蓝色、绿色或红色的。玩家需要用操纵杆上的按钮把球拍的颜色换成球的颜色;如果他们做到了,他们会得到一分,如果没有,他们会失去一分。如果他们错过了球,他们也会失去一分。游戏是计时的,当时间用完时,会给出分数,并有一个重新开始的选项。游戏截图如图 10-4 所示。

A453258_1_En_10_Fig4_HTML.png

图 10-4

A screenshot of the game

操纵杆允许你控制两个方向的运动;它还有一个可以按下的按钮。桨可以在 x 轴和 y 轴上移动。y 轴上的移动很小,但对于玩家来说,如果他们需要更多的时间,就足够向上移动一点来尽早接球,或者向下移动一点。控制面板在 x 轴上的移动被限制在浏览器窗口的宽度范围内。

球使用动画来移动。它在 y 轴上的初始位置正好在浏览器窗口高度的上方。它在 x 轴上的初始位置是浏览器窗口宽度内的一个随机位置。它以恒定的速度下降。

球和桨需要相互作用,所以碰撞对象被添加到两者中,以便它们可以检测何时彼此接触。

游戏的最后三个元素是记分牌、计时器和开始游戏的方式。用户点击消息“开始游戏”开始游戏。然后计时器开始倒数到 0。当玩家试图抓住球时,得分和失分都有。当计时器计时到 0 时,浏览器上会出现一条新消息,显示玩家的最终得分和重新游戏的选项。

Set Up the Joystick

Arduino 游戏手柄的品牌有很多。我用的是 Elegoo 做的一个;它有 5 个引脚,GND(接地)、+5V、VRx(控制 x 轴上的移动)、VRy(控制 y 轴上的移动)和 SW(用于开关、按钮)。根据品牌或操纵杆的不同,针脚可能会反过来。要设置 Arduino,您需要:

  • 5 根公母跨接线
  • 在 Arduino 操纵杆上
  • 在 Arduino

组成如图 10-5 所示,操纵杆的设置如图 10-6 所示。

A453258_1_En_10_Fig6_HTML.jpg

图 10-6

The setup

A453258_1_En_10_Fig5_HTML.jpg

图 10-5

1. An Arduino Uno; 2. A Joystick The Arduino Code

捕捉操纵杆上 x 和 y 运动的针脚是模拟的,开关(按钮)是数字的。这些值被捕获到单个变量中,然后在 Serial.println()函数中连接在一起。每个值前面还会添加一个字母,以便 web 应用程序可以根据需要区分这些值。每个值都由一个“,”字符分隔,这样就可以在 Node.js 服务器中将传入的字符串拆分为一个字符串数组。这些连接如下:

  • 操纵杆上的 GND 连接到 Arduino 上的 GND
  • 操纵杆上的+5V 连接到 Arduino 上的 5V
  • VRx 连接到 A01 引脚
  • VRy 连接到引脚 A02
  • SW 连接到引脚 2(一个数字引脚)

操纵杆没有连接到试验板,所以需要公母线。在 Arduino IDE 中创建新的草图,命名为 chapter _ 10 复制清单 10-3 中的代码。

int xAxisPin = A1;
int yAxisPin = A0;
int buttonPin = 2;
int xPosition = 0;
int yPosition = 0;
int buttonState = 0;

void setup() {
  Serial.begin(9600);
  pinMode(xAxisPin, INPUT);
  pinMode(yAxisPin, INPUT);
  pinMode(buttonPin, INPUT_PULLUP);
}

void loop() {
  xPosition = analogRead(xAxisPin);
  yPosition = analogRead(yAxisPin);
  buttonState = digitalRead(buttonPin);
  xPosition=map(xPosition,0,1023,1,10);
  yPosition=map(yPosition,0,1023,1,10);
Serial.println("x" + (String)xPosition + ",y" + (String)yPosition + ",b" + (String)buttonState);
delay(100);
}

Listing 10-3chapter_10.ino

验证代码,然后将其上传到 Arduino,如果您在 Arduino IDE 中打开串行监视器,您应该会看到来自操纵杆的数据。

代码解释

表 10-3 更详细地解释了 chapter_10.ino 中的代码。

表 10-3

chapter_10.ino explained

| `pinMode(buttonPin, INPUT_PULLUP);` | 因为按钮是一个开关,所以它必须设置为 INPUT_PULLUP,这样 Arduino 就知道当开关打开时给它什么值。 | | `xPosition = analogRead(xAxisPin);``yPosition = analogRead(yAxisPin);` | 在每次循环中,记录 x 轴、y 轴和按钮的值。 | | `xPosition=map(xPosition,0,1023,1,10);` `yPosition=map(yPosition,0,1023,1,10);` | 模拟数据可以在 0 和 1023 之间;这些值被映射到 1 到 10 之间的值。这使得它们更容易在 web 应用程序中使用。 | | `Serial.println("x" + (String)xPosition + ",y" + (String)yPosition + ",b" + (String)buttonState);` | 数据被连接在一起,来自 x 轴和 y 轴的数字以及按钮被转换成字符串,这样它们就可以被发送到 Node.js 服务器。 |

Web 应用程序

游戏将使用 Three.js JavaScript 库编写。Node.js 服务器将类似于本书中的其他应用程序。客户端代码将被分解成多个 JavaScript 文件:一个用于游戏,一个用于计时器,一个用于比分,一个 main.js 文件从 Node.js 服务器获取数据。应用程序的结构将是:

/chapter_10
    /node_modules
    /public
        /css
            main.css
        /javascript
            Countdown.js
            Game.js
            main.js
            Points.js
    /views
        index.ejs
    index.js

Set Up the Node.js Server

本章将再次使用 Express、ejs 和 socket.io。要设置框架应用程序:

  1. 创建一个新文件夹来存放应用程序。我把我的叫做第十章。
  2. 打开命令提示符(Windows 操作系统)或终端窗口(Mac)并导航到新创建的文件夹。
  3. 当你在正确的目录键入 npm init 创建一个新的应用程序;您可以按下 return 键浏览每个问题或对其进行更改。
  4. 现在可以开始添加必要的库了,所以要在命令行下载 Express.js,请键入 npm install express@4.15.3 - save。
  5. 然后安装 ejs,键入 npm install ejs@2.5.6 - save。
  6. 下载完成后,安装串口。在 Mac 上键入 npm 安装 serial port @ 4 . 0 . 7–save,在 Windows PC 上键入 npm 安装 serial port @ 4 . 0 . 7–build-from-source。
  7. 然后最后安装 socket.io,输入 npm install socket.io@1.7.3 - save。

在应用程序的根目录下打开或创建一个 index.js 文件,并复制清单 10-4 中的代码,确保使用 Arduino 连接的串行端口更新串行端口。

var http = require('http');
var express = require('express');
var app = express();
var server = http.createServer(app);
var io = require('socket.io')(server);
var SerialPort = require('serialport');
var serialport = new SerialPort('<add in the serial port for your Arduino>', {
    parser: SerialPort.parsers.readline('\n')
});
app.engine('ejs', require('ejs').__express);
app.set('view engine', 'ejs');
app.use(express.static(__dirname + '/public'));
app.get('/', function (req, res){
    res.render('index');
});
serialport.on('open', function(){
    console.log('serial port opened');
});
io.on('connection', function(socket){
    console.log('socket.io connection');
    serialport.on('data', function(data){
        data = data.replace(/(\r\n|\n|\r)/gm,"");
        var dataArray = data.split(',');
        console.log(dataArray);
        socket.emit("data", dataArray);
    });

    socket.on('disconnect', function(){
        console.log('disconnected');
    });
});

server.listen(3000, function(){
    console.log('listening on port 3000...');
});

Listing 10-4index.js

当通过串行端口从 Arduino 接收数据时,任何额外的字符(如换行符)都会被删除,然后数据被拆分成一个数组,并使用 id 为“data”的 socket io 发出。

由于串行端口功能仅在有套接字 io 连接时检查数据,因此如果启动服务器,您将看不到数据,因为套接字 io 连接将在尚未创建的 main.js 文件中进行。

构建游戏

这个游戏有许多独特的元素,这些都是构建这个游戏的步骤。它们是:

  1. 创建包含 HTML 的主 index.ejs 页面。
  2. 创建 main.js 文件,该文件将有一个从服务器获取数据的套接字。
  3. 使用在 x 轴和 y 轴上移动并带有约束的桨创建场景。
  4. 通过添加可以与球拍碰撞的动画球来更新场景。
  5. 创建一个保存游戏评分代码的 JavaScript 文件。
  6. 创建一个 JavaScript 文件作为计时器。
  7. 增加启动和重启游戏的功能。

Create the Web Page

index.ejs 页面将包含许多 div 元素,这些元素将保存分数、计时器以及开始和重新开始文本。Three.js 场景将被附加到 HTML 的主体。在 views 文件夹中打开或创建 index.ejs 文件,并复制清单 10-5 中的代码。

<html>
    <head>
        <title>three.js basics</title>
        <style>
            body { margin: 0; }
            canvas { width: 100%; height: 100% }
        </style>
    </head>
    <body>
    <script src="/socket.io/socket.io.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r71/three.js"></script>
    <script src="javascript/Game.js"></script>
    <script src="javascript/main.js"></script>
    </body>
</html>
Listing 10-5index.ejs

如果您已经创建了 Game.js 和 main.js 文件,即使它们是空的,您也应该能够运行代码。在控制台中,转到应用程序的根目录,键入 nodemon index.js 或 node index.js。这将启动服务器;您可以通过键入 http://localhost:3000 在 web 浏览器上打开该页面。

Create Main.js

在 public/javascript 文件夹中打开或创建 main.js 文件,并复制清单 10-6 中的代码。

(function(){
    var socket = io();
    socket.on('data', function(data){
        Game.newData(data);
    });
})();
Listing 10-6main.js

创建一个变量来保存套接字。当服务器上运行 id 为“data”的 socket.emit()函数时,main.js 中 id 为“data”的 socket.on()函数接收数据。然后使用 newData()函数将这些数据传递给 Game.js 文件。

Create Game.js

Game.js 文件包含创建 Three.js 场景和网格以及动画的所有代码。它需要来自服务器的数据来知道将桨移动到哪里。它包含一个名为 newData()的函数,该函数接收来自操纵杆的数据并相应地更新拨片。

桨需要留在浏览器窗口内,所以需要检查桨的新位置不会将它带离屏幕。Three.js 中挡板的位置使用不同的坐标来表示挡板在屏幕上的位置。例如,如果屏幕宽度为 625,您希望桨板移动的最大位置将是 625–桨板宽度。Three.js 不懂 625;它有自己的坐标系。对于宽度为 625 的浏览器,面板在 x 轴上的最大位置约为 4,最小位置为-4。在代码中有一个函数进行这种转换,这样你就可以计算出面板相对于浏览器坐标系的位置。

根据操纵杆离其中心位置的距离,操纵杆也会在 x 轴上加速。要做到这一点,来自 Arduino 的轴数据必须被映射到一个新值,该值将用于告诉立方体移动多少以及向哪个方向移动。这必须在正负方向上实现,因此调用 moveObjectAmount()。它做的第一件事是调用一个名为 scaleInput()的函数,该函数从 Arduino 数据中获取号码并映射到一个新号码。这个数字被返回给 moveObjectAmount()函数,在这里它被除以 10,使它足够小,这样桨位置的增加或减少就不会移动得太远。

在 public/javascript 文件夹中打开或创建 Game.js 文件,并复制清单 10-7 中的代码。

var Game = (function(){
    var windowWidth = window.innerWidth;
    var windowHeight = window.innerHeight;
    console.log(windowWidth);
    var scene = new THREE.Scene();
    var camera = new THREE.PerspectiveCamera( 75, windowWidth/windowHeight, 0.1, 1000 );
    var renderer = new THREE.WebGLRenderer();
    renderer.setSize( windowWidth, windowHeight );
    document.body.appendChild( renderer.domElement );
    var geometry = new THREE.BoxGeometry( 2, 0.2, 0.8 );
    var material = new THREE.MeshLambertMaterial( { color: 0x00ff00 } );
    var paddle = new THREE.Mesh( geometry, material );
    scene.add( paddle );

    paddle.position.y = -2;
    camera.position.z = 5;
    var light = new THREE.DirectionalLight(0xe0e0e0);
    light.position.set(5,2,5).normalize();
    scene.add(light);
    renderer.render(scene, camera);
    var newData = function(data){
        updateScene(data);
    }
    var updateScene = function(data){
        var screenCoordinates = getCoordinates();
        var moveObjectBy;
        var x = data[0];
        var y = data[1];
        var button = data[2];
        x = x.substr(1);
        y = y.substr(1);
        button = button.substr(1);
        if(x > 5){
            if(screenCoordinates[0] < windowWidth - 80){
                moveObjectBy = moveObjectAmount(x);
                paddle.position.x = paddle.position.x + moveObjectBy;
            }

        } else if (x < 5){
            if(screenCoordinates[0] > 0 + 80){
                moveObjectBy = moveObjectAmount(x);
                paddle.position.x = paddle.position.x + moveObjectBy;
            }
        }
        if(y > 5){
            if(screenCoordinates[1] < windowHeight - 100){
                paddle.position.y = paddle.position.y - 0.2;
            }

        } else if (y < 5){
            if(screenCoordinates[1] > 0 + 300){
                paddle.position.y = paddle.position.y + 0.2;
            }
        }
        renderer.render(scene, camera);
    }
    var moveObjectAmount = function(x){
        var  output = scaleInput(x);
         output =  utput/10;
         output = Math.round( utput * 10) / 10;
        return  output;
    }
    var scaleInput=function(input){
        var xPositionMin = -4;
        var xPositionMax = 4;
        var inputMin = 1;
        var inputMax = 10;
        var percent = (input - inputMin) / (inputMax - inputMin);
        var  output = percent * (xPositionMax - xPositionMin) + xPositionMin;
        return  output;
    }
    var getCoordinates = function() {
            var screenVector = new THREE.Vector3();
            paddle.localToWorld( screenVector );
            screenVector.project( camera );
            var posx = Math.round(( screenVector.x + 1 ) * renderer.domElement.offsetWidth / 2 );
            var posy = Math.round(( 1 - screenVector.y ) * renderer.domElement.offsetHeight / 2 );
            return [posx, posy];
    }
    return{
        newData: newData
    }
})();

Listing 10-7Game.js

代码解释

表 10-4 更详细的解释了 Game.js 中的代码。

表 10-4

Game. js explained

| `var windowWidth = window.innerWidth;` `var windowHeight = window.innerHeight;` | 浏览器的窗口宽度和高度在代码中被多次使用,因此将它们放在各自的变量中是有意义的。这意味着您只需要在代码中对窗口函数进行两次调用。 | | `var material = new THREE.MeshLambertMaterial( { color: 0x00ff00 } );` | 在清单 10-1 中,桨上的材质使用了一个 THREE.MeshBasicMaterial。这个场景使用了一个 MeshLamabertMaterial 作为基本材质,它对灯光没有反应,而 lambert 材质有反应。 | | `var light = new THREE.DirectionalLight(0xe0e0e0);``light.position.set(5,2,5).normalize();` | 新的平行光被创建,其位置被设置并添加到场景中。Normalize 确保新位置处于正确的方向。 | | `var newData = function(data){``updateScene(data);` | 当新数据来自 Arduino 时,函数 newData 由 main.js 调用。当它被调用时,它将数据传递给 Game.js 函数 updateScene(),然后该函数将处理数据并更新场景。 | | `var updateScene = function(data){``...` | updateScene 数据有一个参数,即来自 Arduino 的数据。 | | `var screenCoordinates = getCoordinates();` | 调用 getCoordintes()函数;这是计算相对于浏览器窗口坐标系而不是 Three.js 坐标系的屏幕坐标的函数。坐标由函数返回并存储在一个变量中。 | | `var moveObjectBy;` | 这个变量将保持桨应该在 x 轴上移动的量。 | | `var x = data[0];``var y = data[1];` | 来自 Arduino 的数据包含 x 轴和 y 轴的信息。和按钮;变量保存数组中相关位置的数据。 | | `x = x.substr(1);``y = y.substr(1);` | Arduino 中的数据在开头包含一个识别字符,JavaScript substr()函数会删除这个字符。 | | `if(x > 5){``...` | 有一系列 if 语句检查 x 和 y 数据是大于还是小于 5。如果大于 5 °,桨的位置将正向更新;少于 5 的将被负向更新。 | | `if(screenCoordinates[0] < windowWidth – 80){``...` | 每个 if 语句内部都有另一个 if 语句。它检查桨的屏幕坐标是否在浏览器窗口的界限内;如果是,可以移动桨。 | | `moveObjectBy = moveObjectAmount(x);` | 如果移动是针对 x 轴的,则来自 Arduino 的数据被映射,以获得桨移动量的值。moveObjectAmount()函数被传递给 x 的 Arduino 值。moveObjectBy 变量保存该函数的返回值。 | | `paddle.position.x = paddle.position.x + moveObjectBy;` | 用新值更新挡板位置,将其添加到挡板的当前位置。 | | `if(screenCoordinates[1] < windowHeight  - 100){``paddle.position.y = paddle.position.y – 0.2;` | 操纵杆的 y 位置的变化是恒定的,因此它将是+0.2 或-0.2,这取决于操纵杆的位置。新值将添加到 y 轴上的桨的当前位置。 | | `renderer.render(scene, camera);` | 当所有的 if 语句都被解析后,渲染器被调用来更新场景。 | | `var moveObjectAmount = function(x){``...` | moveObjectAmount()函数接受一个参数,即来自 Arduino 的 x 值。 | | `var output = scaleInput(x);` | 调用 scaledInput()函数,传递 x 值,并将该调用的返回值放入一个变量中。 | | `output = output/10;` | 10 除以返回值。这使得它足够小,以增加桨的 x 值。 | | `output = Math.round(output * 10) / 10;` | 然后将该值四舍五入到小数点后 1 位。 | | `return output;` | 该值被返回给调用函数。 | | `var scaleInput=function(input){``...` | scaleInput()函数有一个参数,一个需要映射的数字。 | | `var xPositionMin = -4;` `var xPositionMax = 4;` | 两个变量保存输入数字可以映射到的最小和最大数字。 | | `var inputMin = 1;` `var inputMax = 10;` | 两个变量包含输入的最小和最大值。 | | `var percent = (input – inputMin) / (inputMax – inputMin);` `var output = percent * (xPositionMax – xPositionMin) + xPositionMin;` | 计算映射值。 | | `var getCoordinates = function() {``....` | getCoordinates()函数将在 Three.js 坐标空间中查找桨的当前位置,并将该位置转换为浏览器坐标。 | | `var screenVector = new THREE.Vector3();` | 创建一个保存 Three.js 向量的变量。 |

确保 Arduino 已连接到您的电脑,但串行监视器已关闭。在控制台中转到应用程序的根目录,键入 nodemon index.js 或 node index.js 来启动服务器。转到 http://localhost:3000 并打开页面。

Add an Interactive Ball

球需要从浏览器窗口的顶部落下。它要么被桨抓住,要么错过桨。在这些事件中的任何一个之后,它需要被移动到它的初始 y 位置和浏览器窗口中的一个随机的 x 位置,然后再次放下。在球击中球拍之前,球拍需要改变颜色以匹配球。

从清单 10-7 中打开你的 Game.js 文件,并对清单 10-8 中粗体部分进行修改。

var Game = (function(){
    var windowWidth = window.innerWidth;
    var windowHeight = window.innerHeight;
    var colorArray = [0xff0000, 0x00ff00, 0x0000ff];
    var ballColor = Math.floor(Math.random() * 3);
    var colorChoice = 0;
    var collisionTimer = 15;
    var minMaxX = xMinMax(windowWidth);
    minMaxX = (parseFloat((minMaxX/10))-3.0.toFixed(1));

    var scene = new THREE.Scene();
    var camera = new THREE.PerspectiveCamera( 75, windowWidth/windowHeight, 0.1, 1000 );
    var renderer = new THREE.WebGLRenderer();
    renderer.setSize( windowWidth, windowHeight );
    document.body.appendChild( renderer.domElement );
    var geometry = new THREE.BoxGeometry( 2, 0.2, 0.8 );
    var material = new THREE.MeshLambertMaterial( { color: 0x00ff00 } );

    var geometrySphere = new THREE.SphereGeometry( 0.3, 32, 32 );
        var materialSphere = new THREE.MeshLambertMaterial( {color: colorArray[ballColor]}  );

    var paddle = new THREE.Mesh( geometry, material );   

    var ball = new THREE.Mesh( geometrySphere, materialSphere );
    updateBallPosition();

    paddle.position.y = -2;
    camera.position.z = 5;
    var light = new THREE.DirectionalLight(0xe0e0e0);
    light.position.set(5,2,5).normalize();

    scene.add(light);
    scene.add(new THREE.AmbientLight(0x656565));
    scene.add( paddle );
    scene.add( ball );
    // renderer.render(scene, camera);
    var newData = function(data){
        updateScene(data);
    }
    var updateScene = function(data){
        var screenCoordinates = getCoordinates();
        var moveObjectBy;
        var x = data[0];
        var y = data[1];
        var button = data[2];
        x = x.substr(1);
        y = y.substr(1);
        button = button.substr(1);
        if(button ==="0"){
            updatePaddleColor();
        }
        if(x > 5){
            if(screenCoordinates[0] < windowWidth - 150){
                moveObjectBy = moveObjectAmount(x);
                paddle.position.x = paddle.position.x + moveObjectBy;
            }
        } else if (x < 5){
            if(screenCoordinates[0] > 0 + 150){
                moveObjectBy = moveObjectAmount(x);
                paddle.position.x = paddle.position.x + moveObjectBy;
            }
        }
        if(y > 5){
            if(screenCoordinates[1] < windowHeight  - 100){
                paddle.position.y = paddle.position.y - 0.2;
            }

        } else if (y < 5){
            if(screenCoordinates[1] > 0 + 300){
                paddle.position.y = paddle.position.y + 0.2;
            }

        }
        renderer.render(scene, camera);
    }

    var moveObjectAmount = function(x){
        var scaledX = scaleInput(x);
        scaledX = scaledX/10;
        scaledX = Math.round(scaledX * 10) / 10;
        return scaledX;
    }

    var scaleInput=function(input){
        var xPositionMin = -4;
        var xPositionMax = 4;
        var inputMin = 1;
        var inputMax = 10;
        var percent = (input - inputMin) / (inputMax - inputMin);
        var outputX = percent * (xPositionMax - xPositionMin) + xPositionMin;        
        return outputX;
    }
    var getCoordinates = function() {
            var screenVector = new THREE.Vector3();
            paddle.localToWorld( screenVector );
            screenVector.project( camera );
            var posx = Math.round(( screenVector.x + 1 ) * renderer.domElement.offsetWidth / 2 );
            var posy = Math.round(( 1 - screenVector.y ) * renderer.domElement.offsetHeight / 2 );
            return [posx, posy];
    }

    var updatePaddleColor = function(){
        colorChoice++;
        if(colorChoice === 3){
            colorChoice = 0;
        }
         paddle.material.color.setHex( colorArray[colorChoice]);
    }

function randomPosition(num){

    var newPostion = (Math.random() * (0 - num) + num).toFixed(1);
    newPostion *= Math.floor(Math.random()*2) == 1 ? 1 : -1;

    return newPostion;
}   

function xMinMax(input){

    xPositionMin = 4;
    xPositionMax = 184;

        xWindowMin = 200;
        xWindowMax = 2000;

        var percent = (input - xWindowMin) / (xWindowMax - xWindowMin);
        var outputX = percent * (xPositionMax - xPositionMin) + xPositionMin;
        return outputX;
    }
    function updateBallPosition(){
            xPos = randomPosition(minMaxX);
            ball.position.y = 5;
            ball.position.x = xPos;
            ballColor = Math.floor(Math.random() * 3);
            ball.material.color.setHex( colorArray[ballColor]);
    }
    var animate = function () {
        var firstBB = new THREE.Box3().setFromObject(ball);
        var secondBB = new THREE.Box3().setFromObject(paddle);       
        var collision = firstBB.isIntersectionBox(secondBB);
        if(!collision){
            collisionTimer = 15;
            if(ball.position.y > (paddle.position.y - 0.5)){
                ball.position.y -= 0.08;
            } else {
                updateBallPosition();
            }
        }
        if(collision){
            if(collisionTimer > 0){
                collisionTimer = collisionTimer -1;
            } else {
            updateBallPosition();
            }  
        }
        renderer.render(scene, camera);
        requestAnimationFrame( animate );          
    };
    animate();
    return{
        newData: newData
    }
})();

Listing 10-8Game.js first update

代码解释

表 10-5 更详细的解释了 Game.js 首次更新中的代码。

表 10-5

Game.js first update explained

| `var colorArray = [0xff0000, 0x00ff00, 0x0000ff];``var ballColor = Math.floor(Math.random() * 3);` | 包含三个颜色值的数组保存在一个变量中。每当操纵杆按钮被按下时,这个阵列循环改变桨的颜色。colorChoice 变量保存了面板颜色的数组位置。球的颜色是从数组中随机选择的。JavaScript Math.random()函数用于在 0 和 3 之间选择一个数字。 | | `var collisionTimer = 15;` | 当球和桨发生碰撞时,球会被移动到初始的 y 位置,然后再次下落。所以它不会很快消失,因为当发生碰撞时,它们会触动一个设置好并倒计时的计时器。 | | `var minMaxX = xMinMax(windowWidth);` | 当计算球的新 x 位置时,它需要在当前浏览器窗口的宽度内。调用函数 xMinMax 并将其传递给当前浏览器窗口宽度。这个窗口宽度将被映射到一个将球保持在浏览器窗口内的数字。返回值存储在一个变量中。 | | `minMaxX = (parseFloat((minMaxX/10))-3.0.toFixed(1));` | 返回值除以 10,因此它符合 Three.js 坐标系。toFixed()函数将其设置为一个小数位。toFixed()函数返回一个字符串。 | | `updateBallPosition();` | 球的起始位置需要在整个游戏过程中重新设置,updateBallPosition()函数实现了这一点。 | | `// renderer.render(scene, camera);` | 这一行已经被注释掉了,因为它可以被删除,现在调用渲染场景发生在球动画或球拍移动的时候。 | | `if(button ==="0"){``updatePaddleColor();` | 检查来自操纵杆按钮的数据是否为“0”;如果是,则通过调用 updatePaddleColor()函数来改变面板颜色。 | | `var updatePaddleColor = function(){``colorChoice++;``if(colorChoice === 3){``colorChoice = 0;``}``paddle.material.color.setHex( colorArray[colorChoice]);` | colorChoice 变量用作颜色数组的索引。当桨在阵列周围循环时,变量增加 1。如果 colorChoice 变成 3(在数组索引之外),它就变成 0。然后桨上的材料颜色会改变。 | | `function randomPosition(num){``var newPostion = (Math.random() * (0 - num) + num).toFixed(1);``newPostion *= Math.floor(Math.random()*2) == 1 ? 1 : -1;``return newPostion;` | 该函数接受一个数字,并在 0 和传递给它的数字之间计算出一个新的随机数。然后,它随机地使其为正或为负。该函数用于查找球的新 y 位置。 | | `function updateBallPosition(){``xPos = randomPosition(minMaxX);``ball.position.y = 5;``ball.position.x = xPos;``ballColor = Math.floor(Math.random() * 3);``ball.material.color.setHex( colorArray[ballColor]);` | 该函数在球开始运动之前设置球的初始位置。通过调用 randomPosition()函数找到一个随机的 x 位置。y 轴上的初始位置保持不变;它位于浏览器窗口顶部之外。还选择了球的随机颜色。 | | `var firstBB = new THREE.Box3().setFromObject(ball);` `var secondBB = new THREE.Box3().setFromObject(paddle);` | 在球和球拍周围创建一个方框。它们是围绕对象的边界框,用于检查碰撞。 | | `var collision = firstBB.isIntersectionBox(secondBB);` | 函数的作用是:检查第一个边界框(球)是否接触到第二个边界框(球)。变量 collision 保存返回值:如果是接触,则为 true,否则为 false。 | | `if(!collision){` `collisionTimer = 15;` `if(ball.position.y > (paddle.position.y - 0.5)){` `ball.position.y -= 0.08;` `} else {` `updateBallPosition();` `}` `}` | 如果碰撞变量为假,则 collisionTimer 保持在 15。然后,检查球的位置是否低于桨的位置;如果是,球将重置到它的初始 y 位置,并再次开始向下运动。如果不是这样,球就会一直往下掉。 | | `if(collision){``if(collisionTimer > 0){``collisionTimer = collisionTimer -1;``} else {``updateBallPosition();``}` | 如果存在冲突,则检查 collisionTimer 是否大于 0,1 表示 collisionTimer 无效。当它达到 0 时,球被重置到它的初始 y 位置,并再次开始向下运动。 |

如果你重启服务器,你现在应该可以用球拍接住下落的球了。当你抓住它时,它应该等一会儿,然后又开始下落。你也应该能够改变球拍的颜色。

Update Index.ejs

最后的步骤是给游戏打分,创建一个倒计时钟,并有一个开始和重新开始游戏的方法。第一件事是用乐谱和时钟的元素和脚本更新 index.ejs,并创建 main.css 文件。打开清单 10-5 中的 index.ejs 文件,用清单 10-9 中的粗体代码更新它。

<html>
    <head>
        <title>three.js game</title>
        <link href="/css/main.css" rel="stylesheet" type="text/css">
    </head>
    <body>

    <div id = "again" class="hidden">
        <h1>you scored <span id="current-score"></span></h1>
        <h2 id="replay">PLAY AGAIN</h2>
    </div>
    <div id="start">
        <h1>start game</h1>
    </div>

    <div id="score">
        <h1>points: <span id="points"></span></h1>
        <div id="timer">
            <h1 id="countdown"><time></time></h1>
        </timer>
    </div>

    <script src="/socket.io/socket.io.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r71/three.js"></script>

    <script src="javascript/Points.js"></script>
    <script src="javascript/Countdown.js"></script>
    <script src="javascript/Game.js"></script>
    <script src="javascript/main.js"></script>
    </body>
</html>

Listing 10-9Upated index.ejs

你会注意到 CSS 已经消失了,现在有一个链接指向一个 CSS 文件。有许多新的 div 元素;这些显示点和分数以及开始和再次播放按钮。

脚本的顺序很重要。如果在 Game.js 之后添加脚本,Countdown.js 中的某个功能将不会被 Game.js 识别。

HTML 解释道

表 10-6 更详细地解释了更新的 index.ejs 文件的代码。

表 10-6

updated index.ejs explained

| `