十、创建游戏控制器

有了动画,你可以创造任何你能想到的东西:真实的或超现实的,简单的或复杂的。它可以讲述一个故事,也可以是完全抽象的,或者介于两者之间。当 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``materialT2】 | 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;T2】 | 立方体的旋转在 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);T2】 | 在每次循环中,记录 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. 创建一个新文件夹来存放应用程序。我把我的叫做第 10 章。
  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();T2】 | 新的平行光被创建,其位置被设置并添加到场景中。Normalize 确保新位置处于正确的方向。 | | var newData = function(data){``updateScene(data);T2】 | 当新数据来自 Arduino 时,函数 newData 由 main.js 调用。当它被调用时,它将数据传递给 Game.js 函数 updateScene(),然后该函数将处理数据并更新场景。 | | var updateScene = function(data){``...T2】 | updateScene 数据有一个参数,即来自 Arduino 的数据。 | | var screenCoordinates = getCoordinates(); | 调用 getCoordintes()函数;这是计算相对于浏览器窗口坐标系而不是 Three.js 坐标系的屏幕坐标的函数。坐标由函数返回并存储在一个变量中。 | | var moveObjectBy; | 这个变量将保持桨应该在 x 轴上移动的量。 | | var x = data[0];``var y = data[1];T2】 | 来自 Arduino 的数据包含 x 轴和 y 轴的信息。和按钮;变量保存数组中相关位置的数据。 | | x = x.substr(1);``y = y.substr(1);T2】 | Arduino 中的数据在开头包含一个识别字符,JavaScript substr()函数会删除这个字符。 | | if(x > 5){``...T2】 | 有一系列 if 语句检查 x 和 y 数据是大于还是小于 5。如果大于 5 °,桨的位置将正向更新;少于 5 的将被负向更新。 | | if(screenCoordinates[0] < windowWidth – 80){``...T2】 | 每个 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;T2】 | 操纵杆的 y 位置的变化是恒定的,因此它将是+0.2 或-0.2,这取决于操纵杆的位置。新值将添加到 y 轴上的桨的当前位置。 | | renderer.render(scene, camera); | 当所有的 if 语句都被解析后,渲染器被调用来更新场景。 | | var moveObjectAmount = function(x){``...T2】 | 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){``...T2】 | 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() {``....T2】 | 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);T2】 | 包含三个颜色值的数组保存在一个变量中。每当操纵杆按钮被按下时,这个阵列循环改变桨的颜色。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();T2】 | 检查来自操纵杆按钮的数据是否为“0”;如果是,则通过调用 updatePaddleColor()函数来改变面板颜色。 | | var updatePaddleColor = function(){``colorChoice++;``if(colorChoice === 3){``colorChoice = 0;``}``paddle.material.color.setHex( colorArray[colorChoice]);T6】 | 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;T4】 | 该函数接受一个数字,并在 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]);T6】 | 该函数在球开始运动之前设置球的初始位置。通过调用 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();``}T6】 | 如果存在冲突,则检查 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

| <div id = "again" class="hidden"> | 这个 div 会在游戏结束时出现;它有一个“隐藏”类,当游戏结束时,这个类就会被删除。这是在 CSS 中实现的。 | | :-- | :-- | | <h1>you scored <span id="current-score"></span></h1> | 使用 JavaScript,最终分数将显示在这个元素中。 | | <h2 id="replay">PLAY AGAIN</h2> | 按下“再玩一次”,新的游戏开始;这是用 JavaScript 实现的。 | | <h1>points: <span id="points"></span></h1> | 当点更新时,此元素也将更新。 | | <h1 id="countdown"><time></time></h1> | 这个元素保存着倒计时钟。它是使用 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

| canvas {``width: 100%; height: 100%``z-index: -1;T3】 | 画布被设置为 web 浏览器的宽度和高度。画布的 z 索引为-1,它需要位于需要被点击的元素之后。 | | position: absolute;``left: 30;T2】 | id 为“start”、“score”、“again”和“replay”的元素在浏览器页面上都是绝对定位的,CSS 命令 position: absolute。然后,需要使用 left 或 right 命令以及 top 或 bottom 命令给它们在页面上的位置。 | | cursor: pointer; | 使用此命令的元素会在光标经过时将光标变为指针。 | | z-index: 11; | 指定元素的 z 索引。 |

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

| var updatePoints = function(num){``points = points + num;``pointDisplay.innerHTML = points;T3】 | 当调用这个函数时,它被传递一个正数或负数,points 变量被更新,id 为“points”的元素的 innerHTML 也被更新。 | | var getPoints = function(){``return points;T2】 | 这个函数返回当前点。 |

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

| var add = function(stop){``...T2】 | 该函数被传递一个增量,一个布尔值。另一个脚本使用该函数来启动时钟。 | | stopGame = stop; | 变量 stopGame 被赋予传递给它的值。 | | seconds--; | 秒递减。 | | if(seconds === 0 && minutes === 0){``stopGame = true;T2】 | 有一个检查,看看秒和分钟是否在 0,如果是,这是游戏的结束,变量 stopGame 成为真。 | | if(seconds < 0){``seconds = 59;``minutes--;T3】 | 然后检查秒数是否为 0;如果是,这意味着分钟需要减少,秒钟变成 59。 | | countdown.textContent = (hours ? (hours > 9 ? hours : "0" + hours) : "00") + ":" + (minutes ? (minutes > 9 ? minutes : "0" + minutes) : "00") + ":" + (seconds > 9 ? seconds : "0" + seconds); | 三元运算符用于检查输出字符串中应该有哪些前导数字:例如,如果分钟数小于 9,则分钟变量前应该有一个 0。结果字符串更新浏览器页面上的时钟元素。 | | if(!stopGame){``setTimeout(add, 1000);T2】 | 如果游戏仍在进行,倒计时应该继续运行,因此 setTimeout 在 1000 毫秒(1 秒)后再次调用 add 函数。 | | var getStopGame = function(){``return stopGame;T2】 | 这个函数返回 stopGame 的当前值。 |

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 );T2】 | 如果变量 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(){``...T2】 | 当单击 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 概念,让您对技术和代码有一个基本的了解。可以在这些基础结构的基础上构建更加复杂和有趣的项目。