以下是 Charlie Walter 的客座文章。Charlie 致力于 Three.js(使用 WebGL 在浏览器中创建 3D 内容)和游戏概念。如果您对此感兴趣,请继续阅读!
在本教程中,我将解释如何将智能手机连接到 3D 网页游戏的方法。我们将创建一个汽车模型,您可以通过倾斜手机(使用手机的加速计)来控制它。我们将使用 JavaScript 库 three.js 处理 WebGL,以及通过 socket.io 库使用 WebSockets,以及其他一些 Web 技术。
立即尝试
这是一个 实时演示,您可以立即体验。请注意,它在 WiFi 环境下效果最佳。

设置环境
如果您还没有安装 Node,则需要安装它。我们将使用 Express 设置服务器,并使用 socket.io 进行 WebSocket 通信。
为该项目创建一个目录,并将以下 package.json
文件放在根目录下
{
"name": "smartphone-controller-game",
"version": "0.1.0",
"devDependencies": {
"express": "*"
}
}
现在在终端中打开您的项目目录,并使用以下命令安装项目依赖项
npm install
这会查看 package.json
文件,并使用 devDependencies
对象安装正确的依赖项及其版本。NPM 使用 Semvar 版本控制表示法,“*” 表示“最新”。
为了使用 socket.io,我们需要设置一个服务器。这可以通过 Express 来完成。首先,让我们提供一个 index 文件。创建一个包含服务器代码的文件,命名为 server.js
var express = require('express'),
http = require('http'),
app = express(),
server = http.createServer(app),
port = 8080;
server.listen(port);
app
// Set up index
.get('/', function(req, res) {
res.sendFile(__dirname + '/index.html');
});
// Log that the servers running
console.log("Server running on port: " + port);
这会设置一个在端口 :8080 上运行的服务器。当请求根路径(“/”)时,它将在响应中发送 `index.html` 文件。
创建 index.html
文件
<!DOCTYPE html>
<html lang="en">
<head>
<title>Smartphone Controller Game</title>
</head>
<body>
Hello World!
</body>
</html>
现在在终端中运行以下命令
node server.js
它应该显示
Server running on port: 8080
在浏览器中打开 URL localhost:8080
,您的 index 文件应该会被渲染!
启动 Socket 连接
现在我们已经设置好了项目,让我们通过 Socket 让客户端和服务器进行通信。首先我们需要安装 socket.io。
npm install socket.io --save
在 index.html
中,在 <head>
中包含 socket.io
<script src="/socket.io/socket.io.js"></script>
并在 <body>
标签的开头添加
<script>
var io = io.connect();
io.on('connect', function() {
alert("Connected!");
});
</script>
这会连接到 socket.io 并弹出一个消息,让我们知道它正在工作。
在 server.js
中添加以下内容
var io = require('socket.io').listen(server);
io.sockets.on('connection', function (socket) {
console.log("Client connected!")
});
这使用服务器设置了 socket.io,并在新客户端连接时记录日志。
由于服务器代码已更改,我们必须重新运行服务器。按终端中的“Ctrl + C”取消当前进程。每次更新 server.js
时都需要执行此操作。
现在我们应该看到弹出窗口通知我们 socket.io 连接成功,并且终端几乎会立即记录“Client connected!”。
连接手机
现在我们将手机上的浏览器窗口(汽车的控制器)连接到桌面浏览器(游戏)。以下是工作原理
- 游戏客户端会告诉服务器它想要作为游戏连接
- 然后服务器会存储该游戏 Socket,并告诉游戏客户端它已连接
- 然后游戏客户端将使用其 Socket ID 作为 URL 参数创建 URL
- 然后手机(或任何其他标签/窗口)将访问此链接,并告诉服务器它想要作为控制器连接到 URL 中具有 ID 的游戏 Socket
- 然后服务器会存储该控制器 Socket 以及它要连接到的游戏 Socket 的 ID
- 然后服务器将该控制器 Socket 的 ID 分配给相关游戏 Socket 对象
- 然后服务器告诉该游戏 Socket 它已连接了一个控制器,并告诉控制器 Socket 它已连接
- 然后相关游戏 Socket 和控制器 Socket 将
alert()
让我们让游戏客户端告诉服务器它正在作为游戏客户端连接。在 alert()
的位置,添加
io.emit('game_connect');
这会发出一个我们命名为 game_connect
的事件。然后服务器可以监听此事件并存储 Socket,并向客户端发送消息以告知它已连接。因此,添加以下内容作为新的全局变量
var game_sockets = {};
然后在 console.log()
的位置添加以下内容
socket.on('game_connect', function(){
console.log("Game connected");
game_sockets[socket.id] = {
socket: socket,
controller_id: undefined
};
socket.emit("game_connected");
});
当控制器连接时,controller_id
将填充与该游戏连接的控制器的 Socket ID。
现在重新启动服务器并刷新客户端。终端现在应该记录游戏连接。
Game connected
现在服务器向该特定 Socket 发出了名为 game_connected
的事件(该 Socket 发出了 game_connect
),客户端可以监听此事件并创建 URL
var game_connected = function() {
var url = "http://x.x.x.x:8080?id=" + io.id;
document.body.innerHTML += url;
io.removeListener('game_connected', game_connected);
};
io.on('game_connected', game_connected);
将 x.x.x.x
替换为您实际的 IP。要获取此 IP,您可以使用 Mac/Linux 终端中的 `ifconfig` 或 Windows 命令提示符中的 `ipconfig`。这是 IPv4 地址。
当您重新启动服务器并访问客户端时,应该会出现一个指向您 IP 地址(端口 :8080)的 URL,并在末尾添加一个 ID 参数。

太好了!当将该 URL 复制到另一个选项卡(或手动输入到手机中)时,除了创建另一个 URL 之外,什么也不会发生。这不是我们想要的。我们希望当导航到此 URL(带有 ID 参数)时,客户端应识别它具有此参数,并告诉服务器作为控制器连接。
因此,将 io.on('connect', function() {
内的所有内容都包装到以下内容的 else
中
if (window.location.href.indexOf('?id=') > 0) {
alert("Hey, you're a controller trying to connect to: " + window.location.href.split('?id=')[1]);
} else {
// In here
}
加载客户端,当您导航到创建的 URL 时,它会提醒您正在尝试作为控制器连接,而不是作为游戏连接。在这里,我们将它连接到服务器并将其与相关游戏 Socket 关联。

将 alert()
替换为
io.emit('controller_connect', window.location.href.split('?id=')[1]);
这会发出一个名为 controller_connect
的事件,并将 URL 中的 ID 发送到服务器。现在服务器可以监听此事件,存储控制器 Socket 并将其连接到相关游戏。首先,我们需要一个全局变量来存储控制器 Socket
var controller_sockets = {};
在 io.sockets.on('connection', function (socket) { }
内添加以下内容
socket.on('controller_connect', function(game_socket_id){
if (game_sockets[game_socket_id] && !game_sockets[game_socket_id].controller_id) {
console.log("Controller connected");
controller_sockets[socket.id] = {
socket: socket,
game_id: game_socket_id
};
game_sockets[game_socket_id].controller_id = socket.id;
game_sockets[game_socket_id].socket.emit("controller_connected", true);
socket.emit("controller_connected", true);
} else {
console.log("Controller attempted to connect but failed");
socket.emit("controller_connected", false);
}
});
这会检查是否存在具有该 ID 的游戏,并确认它尚未连接控制器。服务器在控制器 Socket 上发出一个名为 controller_connected
的事件,并根据此发送一个布尔值。其成功情况也会被 console.log()
记录。如果检查成功,则会将新的控制器 Socket 与其连接到的游戏的 ID 一起存储。控制器 Socket ID 也会设置在相关现有游戏 Socket 项目上。
现在终端应该会显示控制器连接到游戏的情况。如果我们尝试连接第二个控制器,它将失败(由于验证的第二部分)。
Server running on port: 8080
Game connected
Controller connected
Controller attempted to connect but failed
此外,如果我们编辑 URL 并尝试连接到随机 ID 的游戏 http://x.x.x.x:8080/?id=RANDOMID,它将失败,因为没有具有该 ID 的游戏(验证的第一部分)。如果我们无法启动游戏,也会发生这种情况。
控制器客户端现在可以监听此 'controller_connected' 事件,并根据其成功情况显示消息
io.on('controller_connected', function(connected) {
if (connected) {
alert("Connected!");
} else {
alert("Not connected!");
}
});

断开连接
现在,即使在连接控制器之前游戏标签已关闭,检查游戏是否存在的功能也能正常工作,因为我们还没有添加 Socket 断开连接事件。因此,让我们通过在服务器代码中添加以下内容来实现这一点
socket.on('disconnect', function () {
// Game
if (game_sockets[socket.id]) {
console.log("Game disconnected");
if (controller_sockets[game_sockets[socket.id].controller_id]) {
controller_sockets[game_sockets[socket.id].controller_id].socket.emit("controller_connected", false);
controller_sockets[game_sockets[socket.id].controller_id].game_id = undefined;
}
delete game_sockets[socket.id];
}
// Controller
if (controller_sockets[socket.id]) {
console.log("Controller disconnected");
if (game_sockets[controller_sockets[socket.id].game_id]) {
game_sockets[controller_sockets[socket.id].game_id].socket.emit("controller_connected", false);
game_sockets[controller_sockets[socket.id].game_id].controller_id = undefined;
}
delete controller_sockets[socket.id];
}
});
这会检查断开连接的 Socket 的 ID 是否存在于游戏或控制器集合中。然后它使用连接的 ID 属性(如果 Socket 是控制器,则为“game_id”,如果 Socket 是游戏,则为“controller_id”)通知相关 Socket 断开连接,并将其从相关 Socket 引用中删除。然后删除断开连接的 Socket。这意味着控制器无法连接到已关闭的游戏。
现在,当作为控制器连接到游戏的标签关闭时,终端中应该会显示以下内容
Controller disconnected
添加二维码
如果你一直手动在手机上输入控制器 URL,那么你会很高兴知道现在可以使用 QR 码生成器了。我们将使用这个 QR 码生成器。
将其包含在<head>
中
<script src="//davidshimjs.github.com/qrcodejs/qrcode.min.js"></script>
现在在检查 URL 是否包含参数的else
分支中(我们在其中发出game_connect
事件),添加以下代码
var qr = document.createElement('div');
qr.id = "qr";
document.body.appendChild(qr);
这会创建一个 ID 为“qr”的元素,并将其追加到 body 中。
现在,在我们当前将 URL 写入 body 的地方,将document.body.innerHTML += url;
替换为
var qr_code = new QRCode("qr");
qr_code.makeCode(url);
这使用我们新创建的 ID 为qr
的 div,从提供的 URL 中创建了一个 QR 码(使用该库)。
现在刷新一下!很酷吧?
即使控制器已连接,QR 码仍然存在。因此,让我们在else
分支(我们在其中执行游戏代码的地方)添加以下代码来解决这个问题
io.on('controller_connected', function(connected){
if (connected) {
qr.style.display = "none";
}else{
qr.style.display = "block";
}
});
当接收到controller_connected
事件时,这会更改 QR 码元素的 CSS 样式。现在刷新一下!QR 码现在应该根据控制器的连接状态显示或隐藏。尝试断开控制器的连接。
注意:你的手机必须与你的电脑处于同一网络连接。如果你的手机上出现 504 错误,请尝试调整你的防火墙设置。
构建汽车和地面
好消息。最难的部分已经完成了!现在让我们在 3D 中玩得开心。
首先,服务器必须能够提供静态文件,因为我们将加载一个汽车模型。在server.js
中,在“全局”作用域的任何位置添加以下代码
app.use("/public", express.static(__dirname + '/public'));
在根目录下创建一个public
文件夹,并将 car.js(在这里下载)放入其中。
在index.html
中,在头部包含 3D 库
<script src="//threejs.org/build/three.min.js"></script>
现在设置 three 场景。在声明game_connected
函数之后,我们需要添加一些配置
var renderer = new THREE.WebGLRenderer({
antialias: true
}),
scene = new THREE.Scene(),
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 10000),
// Lights
ambient_light = new THREE.AmbientLight(0x222222),
directional_light = new THREE.DirectionalLight(0xffffff, 1),
// Used to load JSON models
loader = new THREE.JSONLoader(),
// Floor mesh
floor = new THREE.Mesh(new THREE.PlaneBufferGeometry(300,300), new THREE.MeshLambertMaterial({color: 0x22FF11})),
// Render loop
render = function(){
// Render using scene and camera
renderer.render(scene, camera);
if (car)
car.rotation.y += 0.01;
// Call self
requestAnimationFrame(render);
},
car;
// Enable shadows
renderer.shadowMapEnabled = true;
// Moves the camera "backward" (z) and "up" (y)
camera.position.z = -300;
camera.position.y = 100;
// Points the camera at the center of the floor
camera.lookAt(floor.position);
// Moves the directional light
directional_light.position.y = 150; // "up" / "down"
directional_light.position.x = -100; // "left" / "right"
directional_light.position.z = 60; // "forward" / "backward"
// Make the light able to cast shadows
directional_light.castShadow = true;
// Rotates the floor 90 degrees, so that it is horizontal
floor.rotation.x = -90 * (Math.PI / 180)
// Make the floor able to recieve shadows
floor.receiveShadow = true;
// Add camera, lights and floor to the scene
scene.add(camera);
scene.add(ambient_light);
scene.add(directional_light);
scene.add(floor);
// Load the car model
loader.load(
'public/car.js',
function ( geometry, materials ) {
// Create the mesh from loaded geometry and materials
var material = new THREE.MeshFaceMaterial( materials );
car = new THREE.Mesh( geometry, material );
// Can cast shadows
car.castShadow = true;
// Add to the scene
scene.add( car );
}
)
// Set size of renderer using window dimensions
renderer.setSize(window.innerWidth, window.innerHeight);
// Append to DOM
document.body.appendChild(renderer.domElement);
// This sets off the render loop
render();
这设置了 3D 场景,其中包含一个作为地面的 THREE 平面网格、两个灯光(一个环境光和一个方向光,用于投射阴影)以及一个加载的汽车模型,该模型每requestAnimationFrame旋转一次。
详细说明:这声明了所需的 THREE 组件,将摄像机稍微“向上”和“向后”移动,并使用.lookAt
方法(接受 Vector3)旋转摄像机。然后定位方向光并指示其投射阴影。这将使灯光与将其castShadow
或receiveShadow
属性设置为 true 的网格交互。
在本例中,我们希望方向光和汽车投射阴影,地面接收阴影。
地面旋转 -90 度以使其相对于摄像机“水平”,并设置为接收阴影。
摄像机、灯光和地面都添加到场景中。渲染器设置为窗口的尺寸,并放入 DOM 中。
然后,使用 THREE 附带的 JSONLoader 从 public 目录请求汽车模型。回调函数(在模型文件加载后触发)返回模型的几何体和材质,然后用于创建网格。它被设置为投射阴影,并添加到场景中。
最后,触发render()
循环,该循环使用渲染器上的 render 方法使用摄像机渲染场景,如果汽车已加载则旋转汽车(这样我们就可以知道渲染循环是否正常工作),并在requestAnimationFrame
上调用自身。
body {
margin: 0;
}
#QR_code {
position: absolute;
top: 0;
padding: 20px;
background: white;
}
这删除了 canvas 默认情况下具有的不需要的边距,并将 QR 码适当地定位在 canvas 元素之上。
以下是我们应该看到的内容
控制汽车
现在 3D 场景已准备就绪,控制器连接也已正常工作,是时候将两者结合在一起了。首先,让我们为控制器实例创建事件。在“controller_connected”中,在 alert 之后,添加以下代码
var controller_state = {
accelerate: false,
steer: 0
},
emit_updates = function(){
io.emit('controller_state_change', controller_state);
}
touchstart = function(e){
e.preventDefault();
controller_state.accelerate = true;
emit_updates();
},
touchend = function(e){
e.preventDefault();
controller_state.accelerate = false;
emit_updates();
},
devicemotion = function(e){
controller_state.steer = e.accelerationIncludingGravity.y / 100;
emit_updates();
}
document.body.addEventListener('touchstart', touchstart, false); // iOS & Android
document.body.addEventListener('MSPointerDown', touchstart, false); // Windows Phone
document.body.addEventListener('touchend', touchend, false); // iOS & Android
document.body.addEventListener('MSPointerUp', touchend, false); // Windows Phone
window.addEventListener('devicemotion', devicemotion, false);
这会创建一个controller_state
对象,并将事件附加到文档 body 和窗口。accelerate
属性在touchstart
和touchend
上在 true 和 false 之间切换(Windows Phone 等效事件为MSPointerDown
和MSPointerUp
)。steer
属性在devicemotion
上存储手机的倾斜值。
在每个函数中,都会发出一个自定义事件(controller_state_change
),其中包含控制器的当前状态。
现在控制器客户端在状态更改时发送其状态,服务器需要将此信息传递给相关游戏。在server.js
中添加以下代码,在控制器成功连接后,也就是在
game_sockets[game_socket_id].socket.emit("controller_connected", true);
添加以下代码
// Forward the changes onto the relative game socket
socket.on('controller_state_change', function(data) {
if (game_sockets[game_socket_id]) {
// Notify relevant game socket of controller state change
game_sockets[game_socket_id].socket.emit("controller_state_change", data)
}
});
现在服务器已将控制器数据转发到相关游戏套接字,是时候让游戏客户端侦听此数据并使用它了。首先,我们需要在游戏实例的作用域中设置一个controller_state
变量,以便在渲染中访问它(这将充当我们的gameloop
)。在声明car
占位符之后,添加以下代码
var speed = 0,
controller_state = {};
在游戏作用域中的controller_connected
监听器之后,添加以下代码
// When the server sends a changed controller state update it in the game
io.on('controller_state_change', function(state) {
controller_state = state;
});
这是服务器发送新的控制器状态时的监听器,当服务器发送新的控制器状态时,它会更新游戏的控制器状态。
游戏现在在控制器连接的手机触碰屏幕并倾斜时,控制器状态会发生变化,但我们还没有使用此数据,请将
if (car)
car.rotation.y += 0.01;
替换为
if (car) {
// Rotate car
if (controller_state.steer) {
// Gives a number ranging from 0 to 1
var percentage_speed = (speed / 2);
// Rotate the car using the steer value
// Multiplying it by the percentage speed makes the car not turn
// unless accelerating and turns quicker as the speed increases.
car.rotateY(controller_state.steer * percentage_speed);
}
// If controller is accelerating
if (controller_state.accelerate) {
// Add to speed until it is 2
if (speed < 2) {
speed += 0.05;
} else {
speed = 2;
}
// If controller is not accelerating
} else {
// Subtract from speed until 0
if (0 < speed) {
speed -= 0.05;
} else {
speed = 0;
}
}
// Move car "forward" at speed
car.translateZ(speed);
// Collisions
if (car.position.x > 150) {
car.position.x = 150;
}
if (car.position.x < -150) {
car.position.x = -150;
}
if (car.position.z > 150) {
car.position.z = 150;
}
if (car.position.z < -150) {
car.position.z = -150;
}
}
这使用控制器状态中的(如果存在)steer
属性旋转汽车,它乘以速度百分比(当前速度/最大速度),这样汽车在静止时不会旋转,并且汽车的速度随着汽车速度的增加而逐渐加快旋转。
汽车使用speed
向前移动,speed
根据控制器状态中的accelerate
属性逐渐增加或减少。
最后一部分用于碰撞,以防止汽车“离开地面”,为了演示的目的,它是硬编码的,以便汽车的 x 位置保持在 -150 和 150 之间,z 位置也是如此。
现在,最后要做的是在控制器断开连接时重置控制器状态,在重新显示 QR 码之后
QR_code_element.style.display = "block";
添加以下代码
controller_state = {};
现在,当控制器断开连接时,汽车应该停止并重置其转向。
总结
恭喜你坚持到目前为止!如果转向看起来反了,请尝试将手机旋转 180 度,你的方向盘是倒置的!
如果你根据此内容创建了一些很酷的东西,请务必告诉我。在 Twitter 上联系我@cjonasw。
这真的太棒了!!谢谢查理!
点击演示链接后,我收到“应用程序错误”。
我也是。我猜是因为链接发布后,他们已经用完了带宽配额。
无论如何,这是一个很棒的示例代码和说明。
嗨!对此表示歉意,我正在调查!不过,几分钟后似乎可以正常工作了。
太棒了!!打算在家试试
感谢分享,需要尝试一下。
好吧……我的周末就这样泡汤了!很棒的教程。感谢分享。
很棒的例子!谢谢!
感谢指南。我迫不及待地想尝试一下!
在使用教程代码时,Android 智能手机似乎存在问题,iOS 和其他浏览器实例事件可以正常工作。我找不到问题所在,并且查看了您的 Android 可以正常工作的在线示例。
你能指点我正确的方向吗?
嗨,Bogdan,
你是否将代码放在某个地方以便我查看?如果你愿意,可以给我发送电子邮件:[email protected]
这个教程非常好。但是你仍然需要改进文章的写作流程。哪些代码放在哪里很不清楚。也许提供一个下载结果链接会有所帮助。我还没有成功运行它。
嗨,
我把代码上传到了Github https://github.com/cjonasw/smartphone-controlled-3d-web-game
希望这有帮助
查理
感谢分享这篇文章。我正在尝试这个。到目前为止,我没有遇到任何问题。这个网页游戏教程对像我这样的初学者非常有帮助。
很棒的教程,非常喜欢这个概念。马上就开始尝试!
哇,又是一篇好文章。真的很有帮助。谢谢你们…