如何制作一款智能手机控制的 3D 网页游戏

Avatar of Charlie Walter
Charlie Walter 发布

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

以下是 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)旋转摄像机。然后定位方向光并指示其投射阴影。这将使灯光与将其castShadowreceiveShadow属性设置为 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属性在touchstarttouchend上在 true 和 false 之间切换(Windows Phone 等效事件为MSPointerDownMSPointerUp)。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