将 Arduino 连接到 Web(三)
八、创建 Web 仪表板
您可以将传感器连接到 Arduino,并将数据发送到前端,以创建物联网仪表盘。在本章中,你将使用热、光和湿度传感器来收集数据,然后显示在网页上。仪表板上的可视化将对实时数据做出反应,这些数据将被存储,以给出每日的最高和最低读数。通过以这种方式使用数据可视化,您可以使数据更容易阅读、理解和分析。
仪表板
本章中的仪表板将获取温度、湿度和光照水平数据,并在环形图中显示每个数据。数据将存储在服务器上,存储在一个简单的 JavaScript 对象中,每天都会重置。每次值改变时,对象将被传递到前端,以便仪表板显示数据的准确图片。图 8-1 显示了仪表板在浏览器中的外观。
图 8-1
The dashboard application for this chapter
数据可视化原理
我们将数据可视化,以便更好地理解它,无论是探索数据、传达信息还是用数据讲述故事。数据可视化由点、线、区域、表面或体积组成。这些可以被修改成 Jacques Bertin 所说的视觉变量。他定义了七个视觉变量:位置、大小、价值、纹理、颜色、方向和形状。图 8-2 显示了七个视觉变量。
图 8-2
The seven visual variables and how they are related to points, lines, and area
随着时间的推移,其他研究人员加入了这些视觉变量。您使用哪些可视变量来表示数据将取决于数据的类型。数量型、序数型和分类型数据适用于某些变量。
定量数据是有数量的数据,例如,一袋中苹果的数量。有序数据是具有我们给定的顺序的数据,例如,你的前 10 部电影。分类数据用于标注,没有与之相关的数字,例如,国家列表。
有许多不同类型的图表可以用来表示数据,您选择哪种类型取决于您拥有的数据类型和您想要表达的内容。当选择您想要创建的可视化类型时,考虑谁将查看它,它需要有什么样的复杂程度,以及可视化是否使数据易于理解。
可视化的类型
您可以制作多种类型的图表来可视化数据,其中一些如图 8-3 所示。
图 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 实现这一点;但是你可能会发现另一种传感器的不同步骤。
- 打开 Arduino IDE。
- 在菜单中,转到草图/包含库/添加。ZIP 库,将会打开一个窗口。
- 导航到 ZIP 文件,双击该文件将其导入。
- 您现在应该能够看到导入的库,因此通过查看菜单“草图/包含库”进行检查,您应该能够在库列表中看到库的名称。
Note
不同类型的温度和湿度传感器有不同的可下载库。如果您使用了不同品牌的传感器,您将使用其库,因此 ino 代码会略有不同。请参考您的传感器指南,找到正确的代码。
Set Up the Temperature and Heat Sensors
要设置温度和湿度,您需要一个温度和湿度传感器、一个 Arduino Uno、一根 USB 电缆和一根母线对公线。图 8-4 显示了组件。
图 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 显示了部件应该如何连接。
图 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
验证代码,然后通过 USB 将 Arduino 连接到端口,将草图上传到 Arduino。确保您在工具菜单中为 Arduino 选择了正确的端口:工具/端口。
现在为你的草图打开串行监视器,你应该看到每 10 秒钟就有一次数据传来。
Adding a Photoresistor
从电脑上拔下 Arduino 以设置光敏电阻。更新后的设置如图 8-6 所示。
图 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
将更新的草图上传到 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
The Dashboard Application
现在传感器已经设置好了,您可以为数据创建仪表板应用程序了。首先构建框架应用程序,其结构如下所示:
/chapter_08
/node_modules
/public
/css
main.css
/javascript
main.js
donut.js
/views
index.ejs
index.js
创建服务器的设置与前面章节中的相同:
- 创建一个新文件夹来存放应用程序。我叫我的章 _08。
- 打开命令提示符(Windows 操作系统)或终端窗口(Mac)并导航到新创建的文件夹。
- 在正确的目录中,键入 npm init 创建一个新的应用程序;您可以按下 return 键浏览每个问题或对其进行更改。
- 您现在可以开始添加必要的库;要在命令行下载 Express.js,请键入 npm install express@4.15.3 - save。
- 然后安装 ejs,键入 npm install ejs@2.5.6 - save。
- 下载完成后,安装串口。在 Mac 上,键入 NPM install serial port @ 4 . 0 . 7–save;在 Windows PC 上,键入 NPM install serial port @ 4 . 0 . 7-build-from-source。
- 然后最后安装 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 所示使用它。
图 8-7
Basic code for the node-schedule library
该系统由一系列星星组成,从左到右代表秒(可选)、分钟、小时、一个月中的日子、一个月和一周中的日子。图 8-7 中的代码将在每分钟过去 10 秒时记录文本。图 8-8 显示了呼叫的格式
图 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>°C</p>
</div>
<div class="low">
<p>today's low</p>
<p><span id="temp-low">27</span>°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 的例子。
图 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 显示了一个简单回调函数的例子。打印数字的方法作为参数传递给创建数字的函数。
图 9-2
An example of a synchronous callback function
这是一个简单的回调函数的同步例子。它们对于异步函数非常有用,因为作为参数传递的函数只有在异步函数完成某些事情时才会被调用。当你调用外部服务器时,你需要等待它返回一些东西给你,然后才能运行一个函数;这可以通过回调函数来完成。图 9-3 显示了一个向外部服务器发出请求的异步回调的伪代码示例。伪代码是一种不使用特定编程语言来解释代码如何工作的方法;它不会作为一段代码运行。
图 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 显示了它们是如何工作的。
图 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 特征的示例。
图 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 请求:
- 创建一个新文件夹来存放应用程序。我叫我的章 _09。
- 打开命令提示符(Windows 操作系统)或终端窗口(Mac)并导航到新创建的文件夹。
- 当你在正确的目录键入 npm init 创建一个新的应用程序;您可以按下 return 键浏览每个问题或对其进行更改。
- 下载完成后,安装串口。在 Mac 类型 npm 上安装 serial port @ 4 . 0 . 7-save;在 Windows PC 上,键入 NPM install serial port @ 4 . 0 . 7-build-from-source。
- 下载 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
Arduino 组件
该应用将使用 LED、LCD 和压电蜂鸣器;将首先设置 LED 和压电。
压电蜂鸣器
压电蜂鸣器产生声音。它包含一种压电材料。压电材料在通电时会改变形状,从而产生声音。材料弯曲得越快,频率越高,声音也越大。Arduino 有一个称为 tone 的功能,它控制压电;它有两个论点。第一个参数是压电元件所附着的管脚号,第二个参数是压电元件的频率。
Set Up the Led And Piezo
首先,您只需设置压电和 LED,并让它们工作。图 9-6 显示了您将需要的组件:
图 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 和组件。
图 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
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
Add An LCD To The Arduino
当 Arduino 接收到新的地震数据时,LCD 将开始显示数据,LED 将闪烁,蜂鸣器将蜂鸣。几秒钟后,闪烁和嗡嗡声将停止。LCD 的设置与第五章中的设置相同。这也意味着 LCD 的字符限制为 64 个字符。对于传递给它的大多数数据来说,这应该没问题,但是较长的字符串会被截断。部件如图 9-8 所示,具体如下:
图 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。
图 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
当草图上传到 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 轴。
图 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 显示了三维物体的点、线和面。
图 10-2
A 3D object
着色器
着色器计算如何在 3D 空间中渲染对象。着色器在对象设置动画时计算出对象的位置和颜色。他们还计算出物体的每个部分相对于其位置和场景中其他元素(如照明)的明暗程度。WebGL 使用 GPU 的顶点着色器和片段着色器来渲染场景。
顶点明暗器
3D 中的对象由顶点组成。顶点着色器处理这些顶点中的每一个,并在屏幕上给它一个位置。它将顶点的三维位置转换成屏幕上的 2D 点。
片段着色器
片段着色器在渲染对象时计算出对象的颜色。可以有许多元素决定对象的颜色,包括其材质颜色和场景中的照明。WebGL 通过在三个顶点之间创建三角形来构成对象的面。片段着色器计算出每个顶点的值,并在顶点处插值以计算出整体颜色。渲染所涉及的基本步骤如图 10-3 所示。
图 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
Table
游戏
您在本章中创建的游戏将使用 Three.js 作为浏览器,并使用 Node.js 服务器通过串行端口从 Arduino 获取数据。这是一个简单的游戏,玩家试图抓住一个球拍上的球。附在 Arduino 上的操纵杆控制着船桨。操纵杆离中心位置越远,叶片在 x 轴上的移动速度就越快。球可以是蓝色、绿色或红色的。玩家需要用操纵杆上的按钮把球拍的颜色换成球的颜色;如果他们做到了,他们会得到一分,如果没有,他们会失去一分。如果他们错过了球,他们也会失去一分。游戏是计时的,当时间用完时,会给出分数,并有一个重新开始的选项。游戏截图如图 10-4 所示。
图 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 所示。
图 10-6
The setup
图 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
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。要设置框架应用程序:
- 创建一个新文件夹来存放应用程序。我把我的叫做第十章。
- 打开命令提示符(Windows 操作系统)或终端窗口(Mac)并导航到新创建的文件夹。
- 当你在正确的目录键入 npm init 创建一个新的应用程序;您可以按下 return 键浏览每个问题或对其进行更改。
- 现在可以开始添加必要的库了,所以要在命令行下载 Express.js,请键入 npm install express@4.15.3 - save。
- 然后安装 ejs,键入 npm install ejs@2.5.6 - save。
- 下载完成后,安装串口。在 Mac 上键入 npm 安装 serial port @ 4 . 0 . 7–save,在 Windows PC 上键入 npm 安装 serial port @ 4 . 0 . 7–build-from-source。
- 然后最后安装 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 文件中进行。
构建游戏
这个游戏有许多独特的元素,这些都是构建这个游戏的步骤。它们是:
- 创建包含 HTML 的主 index.ejs 页面。
- 创建 main.js 文件,该文件将有一个从服务器获取数据的套接字。
- 使用在 x 轴和 y 轴上移动并带有约束的桨创建场景。
- 通过添加可以与球拍碰撞的动画球来更新场景。
- 创建一个保存游戏评分代码的 JavaScript 文件。
- 创建一个 JavaScript 文件作为计时器。
- 增加启动和重启游戏的功能。
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
确保 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
如果你重启服务器,你现在应该可以用球拍接住下落的球了。当你抓住它时,它应该等一会儿,然后又开始下落。你也应该能够改变球拍的颜色。
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
you scored
` | 使用 JavaScript,最终分数将显示在这个元素中。 | | `PLAY AGAIN
` | 按下“再玩一次”,新的游戏开始;这是用 JavaScript 实现的。 | | `points:
` | 当点更新时,此元素也将更新。 | | `` | 这个元素保存着倒计时钟。它是使用 JavaScript 更新的。 |Create Main.css
在 public/css 中打开或创建一个名为 main.css 的文件,并从清单 10-10 中复制 css。
*{
margin: 0;
padding: 0;
}
body {
margin: 0;
}
body {
font-family: Verdana, Arial, sans-serif;
}
canvas {
width: 100%; height: 100%
z-index: -1;
}
#start{
position: absolute;
left: 30;
top: 40;
color: white;
cursor: pointer;
background: red;
z-index: 10;
}
#start h1{
padding: 6px;
}
#score{
position: absolute;
left: 30;
top: 10;
color: white;
width: 200px;
height: 120px;
}
#score h1{
font-size: 16px;
}
#again{
position: absolute;
left: 30;
top: 40;
color: white;
cursor: pointer;
z-index: 10;
}
#again h2{
margin-top: 4px;
}
#replay{
cursor: pointer;
z-index: 11;
background: red;
}
.hidden{
visibility: hidden;
}
Listing 10-10main.css
CSS 解释道
你希望分数和倒计时钟在游戏中。因此,它们必须被绝对地放置在页面上。这些元素还必须具有比画布更高的 z 索引。z 索引指定元素相互堆叠的顺序。具有较低 z 索引的元素被放置在具有较高 z 索引的元素之下。表 10-7 更详细地解释了 main.css 中的 CSS。
表 10-7
main.css explained
Create Points.js
您需要编写 JavaScript 来计算和显示这些点。在 public/javascript 中打开或创建一个名为 Points.js 的文件,并将清单 10-11 中的代码复制到其中。
var Points = (function(){
var points = 0;
var pointDisplay = document.getElementById("points");
var resetPoints = function(){
points = 0;
}
var updatePoints = function(num){
points = points + num;
pointDisplay.innerHTML = points;
}
var getPoints = function(){
return points;
}
return{
resetPoints: resetPoints,
updatePoints: updatePoints,
getPoints: getPoints
}
})();
Listing 10-11Points.js
代码解释
表 10-8 更详细地解释了 Points.js 中的代码。
表 10-8
Points.js explained
Create a Countdown Clock
你需要一个倒计时钟来为比赛计时。一个布尔变量控制倒计时钟开始和停止的时间。当变量“stopGame”为假时,时钟将运行。运行时钟的函数检查每个循环,看时钟是否为零;如果是,变量“停止游戏”被设置为真。它有一个函数返回“stopGame”的值,其他脚本可以使用该值来检查游戏是否应该结束。
If 有一个名为 add 的函数,它允许其他脚本启动时钟。该函数中有对当前时间的检查,以及一个三元运算符,用于检查时钟显示的零点位置。
三元运算符是 if/else 语句的不同形式。if else 语句
if(condition){
do something
} else{
do a different thing
}
可以写成
var statementResult = (condition) ? do something : do a different thing;
将 if 和 else 结果分开
您可以使用 init()函数中的倒计时时钟来设置游戏运行的小时、分钟和秒。
在 public/javascript 文件夹中打开或创建 Countdown.js 文件,并复制清单 10-12 中的代码。
var Countdown = (function(){
var countdown = document.getElementById('countdown');
var seconds;
var minutes;
var hours;
var stopGame;
var init = function(){
hours = 0;
seconds = 25;
minutes = 1;
}
var add = function(stop){
stopGame = stop;
seconds--;
if(seconds === 0 && minutes === 0){
stopGame = true;
}
if(seconds < 0){
seconds = 59;
minutes--;
}
countdown.textContent = (hours ? (hours > 9 ? hours : "0" + hours) : "00") + ":" + (minutes ? (minutes > 9 ? minutes : "0" + minutes) : "00") + ":" + (seconds > 9 ? seconds : "0" + seconds);
if(!stopGame){
setTimeout(add, 1000);
}
}
var getStopGame = function(){
return stopGame;
}
return{
init:init,
add: add,
getStopGame: getStopGame
}
})();
Listing 10-12Countdown.js
代码解释
表 10-9 更详细的解释了 Countdown.js 中的代码。
表 10-9
Countdown.js explained
Update Game.js
Game.js 文件必须更新以包含时钟和分数。打开清单 10-8 中的 Game.js 文件,复制清单 10-13 中的粗体代码。
var Game = (function(){
var currentScore = document.getElementById('current-score');
var playAgainElement = document.getElementById('again');
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 stopGame;
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 );
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];
}
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 updatePaddleColor = function(){
colorChoice++;
if(colorChoice === 3){
colorChoice = 0;
}
paddle.material.color.setHex( colorArray[colorChoice]);
}
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 {
Points.updatePoints(-1);
updateBallPosition();
}
}
if(collision){
if(collisionTimer > 0){
collisionTimer = collisionTimer -1;
} else {
var tempPaddleColor = paddle.material.color;
var tempBallColor = ball.material.color;
if((tempBallColor.r === tempPaddleColor.r)&& (tempBallColor.g === tempPaddleColor.g) && (tempBallColor.b === tempPaddleColor.b) ){
Points.updatePoints(1);
} else {
. Points.updatePoints(-1);
}
updateBallPosition();
}
}
renderer.render(scene, camera);
// requestAnimationFrame( animate );
stopGame = Countdown.getStopGame();
if(!stopGame){
requestAnimationFrame( animate );
} else {
updateBallPosition();
var gamePoints = Points.getPoints();
currentScore.innerHTML = gamePoints;
playAgainElement.classList.remove("hidden");
}
};
// animate();
return{
newData: newData,
animate: animate
}
})();
Listing 10-13Game.js second update
代码解释
大部分变化都围绕着动画功能。现在,您只希望游戏在玩家按下 start 时运行。animate 函数现在被返回,因此它可以被 main.js 调用,main . js 可以访问开始按钮。现在有一个调用来检查倒计时脚本中 stopGame 的值,只有当它为 false 时才会调用 requestAnimateFrame()函数。如果是真的,游戏就结束了。必须重置球的位置和点数,并且必须看到“重新播放”元素。表 10-10 更详细的解释了 Game.js 第二次更新中的代码。
表 10-10
Game.js second update explained
| `stopGame = Countdown.getStopGame();` | 在 requestAnimateFrame 的每个循环中,需要倒计时时钟的当前状态;这是通过调用它的 getStopGame()函数来完成的,调用的结果是一个放置在 StopGame 变量中的布尔值。 | | `if(!stopGame){``requestAnimationFrame( animate );` | 如果变量 stopGame 为 false,则游戏仍在进行,因此调用 requestAnimateFrame(animate)再次运行动画循环。 | | `else {` `updateBallPosition();` `var gamePoints = Points.getPoints();` `currentScore.innerHTML = gamePoints;` `playAgainElement.classList.remove("hidden");` | 如果变量 stopGame 为真,游戏结束,调用 else 语句。这将调用函数 updateBallPosition()来重置球。它还获取当前的游戏点,并在 id 为“current-score”的 HTML 元素上设置 innerHTML。id 为“again”的 HTML 元素的隐藏类已被移除,因此可以在 web 浏览器上看到它。 | | `// animate();` | 在这个位置不再调用 animate 函数;动画在 main.js 中启动。 |Update Main.js
最后,您需要更新清单 10-6 中的 main.js 文件,打开它,并复制清单 10-14 中的粗体代码。
(function(){
var socket = io();
var stopGame = false;
var startElement = document.getElementById('start');
var playAgainElement = document.getElementById('again');
socket.on('data', function(data){
Game.newData(data);
});
startElement.addEventListener("click", function(){
Countdown.init();
Countdown.add(stopGame);
Points.updatePoints(0);
Game.animate();
startElement.classList.add('hidden');
});
playAgainElement.addEventListener("click", function(){
stopGame = false;
Points.resetPoints();
Points.updatePoints(0);
Countdown.init();
Countdown.add(stopGame);
Game.animate();
playAgainElement.classList.add("hidden");
})
})();
Listing 10-14main.js updated
代码解释
当玩家点击开始按钮时,游戏需要开始。在游戏结束时,会出现“再次游戏”按钮,当玩家点击它时,游戏需要重新开始。这两个操作都发生在 main.js 文件中。
变量保存对 id 为“开始”和“再次”的 HTML 元素的引用。它们有 click 事件侦听器,这些侦听器调用函数来启动游戏并更新前端。表 10-11 更详细地解释了更新后的 main.js 文件中的代码。
表 10-11
main.js update explained
| `var stopGame = false;` | 变量保存布尔值 false 这将被传递到倒计时钟启动它。 | | `startElement.addEventListener("click", function(){``...` | 当单击 HTML 开始元素时,会调用许多函数。 | | `Countdown.init();` | 调用倒计时时钟初始化方法,将时钟设置为初始值。 | | `Points.updatePoints(0);` | 调用 updatePoints 方法将点重置为 0。 | | `Game.animate();` | 在 Game.js 文件中调用 animate 函数来启动球动画。 | | `startElement.classList.add('hidden');` | 持有文本开始的元素在浏览器页面上是隐藏的。 | | `playAgainElement.addEventListener("click", function(){` `})` | 当单击 HTML play again 元素时,会调用一些函数来重新开始游戏。 |现在,如果您重新启动服务器,更改应该会生效。如果你在网页浏览器上进入 http://localhost:3000,你应该可以玩这个游戏。
摘要
本章已经给了你用 Three.js 创建一个应用程序所需要的基础知识,以及你如何用 Arduino 来使用它。能够在网页上使用复杂的动画真的打开了与 Arduino 交互的可能性。
在本书中,引入了新的 JavaScript 和 Arduino 概念,让您对技术和代码有一个基本的了解。可以在这些基础结构的基础上构建更加复杂和有趣的项目。