HTML5 多人飞机游戏
关于制作基于物理的多人 HTML5 游戏的简短指南。
背景
我有一段时间没有好好找借口拖延了,所以我不能确定,但我相信 HTML5 游戏在互联网上越来越普遍,并正在取代 Flash 游戏。它们不再仅仅是为开发者或概念验证而存在的——它们是完全成熟的游戏。有许多精彩的视觉益智游戏示例,它们既令人眼花缭乱又充满乐趣。我打算展示创建一款快速移动的动作类多人游戏同样容易。
从小我就对飞行着迷,所以制作一款涉及飞机的游戏一直都在我的待办事项清单上。由于我的 GL 技能不达标,我只能停留在二维世界。2D 横向卷轴游戏一直困扰我的一件事是缺乏物理特性。移除一个完整的维度已经是一个很大的飞跃,但在一个没有物理特性的世界中设置游戏真的令人失望。向上箭头不应该让飞机垂直向上移动——它应该使飞机俯仰。在快速处理器和硬件加速出现之前,忽略物理特性有很好的理由,但那些日子已经一去不复返了。
我决定我的游戏应该具有以下特点:
- 半真实的物理特性 - 它是 2D 的,所以不会太真实
- 多人游戏 - 使用 WebSockets
- 跨平台 - 基于 HTML5
- 流畅 - 飞机不应该跳动,渲染应该流畅
- 简单 - 我不想要太多花哨的功能,只需要基本功能
我没有把“有趣”添加到列表中,因为它不能保证——没有得分或目标,但如果你像我一样,“半真实的物理特性”就意味着有趣!
代码
客户端有一个单一的 HTML 页面和 8 个小型 JavaScript 文件。每个 JavaScript 文件负责游戏的不同部分。
- utilities.js - 为原型添加了一些函数,例如 Array.indexOf
- polyfills.js - 包含跨浏览器兼容性所需的任何函数
- user-controller.js - 处理键盘用户输入
- io.js - 处理与基于套接字的服务器的双向通信
- game-loop.js - 指示模型按适当的时间间隔向前推进和渲染
- plane.js - 处理飞机的物理特性并跟踪坐标、速度等
- game.js - 主游戏模型。它存储所有飞机、游戏地图,并负责渲染和推进模拟
- game-map.js - 表示飞机可以在其中移动的地图
游戏循环
在 JavaScript 中执行重复任务的经典方法是使用“setInterval”或“setTimeout”,它们分别以重复的间隔或在超时后调用函数。现代浏览器还带有一个新方法“requestAnimationFrame”。关于它们之间的区别有很多博客文章,但为了简洁起见,我将强调使用 requestAnimationFrame 的重要优点:
- 您不需要设置明确的间隔,您是在下一个可用时间请求新帧,而不是强制要求。浏览器将旨在优化此调用的频率,以产生流畅的动画。
- 如果您滚动离开画布或切换选项卡,则无需继续重绘游戏。当您的渲染区域不可见时,您的浏览器会很好地停止触发您的 requestAnimationFrame 回调(或至少限制其调用速率)。
- 浏览器将尽力优化渲染,我不确定这有多大帮助,但这总归是好事。
这是否意味着是时候停止使用 setInterval 并将所有内容都放在 requestAnimationFrame 中了?当然不是!这是一个非常糟糕的主意,而且我经常看到这样的情况。在每个时间步计算飞机如何移动时,会有少量的计算。如果游戏以 60 fps 渲染,飞机将每秒更新其位置 60 次;这对处理器来说是过度的消耗。我希望控制模拟更新的频率。当我没有查看它时,模拟不应该停止或减慢,只有渲染应该停止。因此,继续使用 setInterval 或 setTimeout 来更新游戏循环——只有渲染代码才应该放在 requestAnimationFrame 的回调中。注意:您仍然不能保证 setInterval 会在您想要的时候触发,例如 Chrome 会限制后台选项卡。
如果对象的位置更新,例如,每秒一次,您可能会预期动画非常跳跃,因此在更新之间会产生多余的 requestAnimationFrame 调用。为了确保流畅的动画,您可以在渲染循环中使用一个非常巧妙的技巧。记住对象的前一个位置并进行插值。如果您的渲染循环在游戏循环步骤之间被调用了 30%,则将您的对象绘制在其前一个位置和下一个位置之间的 30% 处。这确保了流畅的动画,无论对象位置更新频率如何。这增加了视图和模型之间的分离。您可以在 gameLoop 代码中看到此功能。alpha
是执行渲染时游戏在当前时间步中进行到多远的百分比。
function gameLoop(game) {
var desiredDt = 50;
var previousGameTime = new Date();
// Update the model with set interval
setInterval(gameLoop, desiredDt);
// Update the canvas with requestAnimationFrame
requestAnimationFrame(renderLoop);
function renderLoop() {
// alpha is the fraction of how far through the current time step is being rendered
alpha = (new Date() - previousGameTime) / desiredDt;
game.render(alpha);
requestAnimationFrame(renderLoop);
}
function gameLoop() {
// Update positions etc
var now = new Date();
game.step((now - previousGameTime) * 0.001);
previousGameTime = now;
}
}
物理
游戏中的物理特性不需要复杂才能看起来不错。首先,每个飞机对象都包含将其向前推进所需的所有属性,这些属性存储在一个方便的对象中。
this.planeDetails = { rotation : 0, x : 0, y : 0, vx : 0, vy : 0, thrust : false, rotateAntiClockwise : false, rotateClockwise : false };
rotation
是飞机与地面形成的角度x
和y
是飞机的 x 和 y 坐标vx
和vy
是速度的水平和垂直分量thrust
在飞机向前加速时为 true(用户按住向上键)rotateAntiClockwise
和rotateClockwise
在用户按住左或右键旋转飞机时为 true
底部三个属性需要用户输入,其余属性由模拟更新。任何物理模拟的第一步都是绘制经过验证的力图(或至少在脑海中描绘它)。
图片中的力足以使飞机移动和飞行,但还需要进行一个小小的补充。如果飞机向上俯冲,就像开始一个循环一样,升降舵应该增加升力并将飞机向后推。
重力和推力可以任意设定。升力应与飞机“向前”移动的速度成正比。静止的飞机不产生升力,垂直指向水平移动的飞机也不产生升力。阻力/摩擦力也与飞机的速度成正比,静止的飞机没有摩擦力。
计算飞机受力的代码如下所示。有很多对余弦和正弦的引用——如果您想要某个事物的水平分量,请将其乘以其角度的余弦;同样,要获得垂直分量,请将其乘以其角度的正弦。游戏中的代码不会比这更复杂:
// Relative strengths of forces var friction = 0.2; var thrust = this.planeDetails.thrust ? 300 : 0; var gravity = 600; var cos = Math.cos(this.planeDetails.rotation); var sin = Math.sin(this.planeDetails.rotation); // This is the component of the plane's velocity in the direction that the plane pointing var forwardSpeed = Math.abs(cos * this.planeDetails.vx + sin * this.planeDetails.vy); // Maneuverability describes the strength of the force generated by the wings // The more air rushing over the wings, the greater the force. Cap it at 2000. var elevatorForce = Math.min(2000, 1.6*forwardSpeed); var elevatorForceX = 0; var elevatorForceY = 0; var drotation = 0; // Rotating the plane uses the elevators which also force the plane in the x and y direction if (this.planeDetails.rotateAntiClockwise) { drotation = 1.5 elevatorForceY = cos * elevatorForce; elevatorForceX = -sin * elevatorForce; } else if (this.planeDetails.rotateClockwise) { drotation = -1.5 elevatorForceY = -cos * elevatorForce; elevatorForceX = sin * elevatorForce; } // Wings will generate a force even if the elevators aren't pitched. // Only include this force is the plane isn't pitching upwards otherwise it goes up too fast if (elevatorForceY <= 0) { elevatorForceY += 0.6*Math.abs(cos * forwardSpeed); } var forceX = cos * thrust + elevatorForceX - this.planeDetails.vx*friction; var forceY = sin * thrust + elevatorForceY - this.planeDetails.vy*friction - gravity;
如果我说我第一次就搞对了升降舵力的符号,那我在撒谎——反复试验效果很好。如果飞机向错误的方向移动,只需翻转一个符号即可。
计算力是这种简单模拟中唯一棘手的部分。我们试图在每一步更新的是位置和速度。有许多不同的“积分器”可以接收力并更新位置和速度。对于这样的游戏,我出于三个原因使用半隐式欧拉方法:
- 实现简单
- 速度快
- 稳定且相当准确
它由两个非常简单的规则定义
- 下一时间步的速度是当前速度加上当前加速度乘以时间变化。
- 下一时间步的位置是当前位置加上刚刚计算出的速度乘以时间变化。
加速度就是力除以质量——为了简单起见,我们给飞机一个质量为一。这是代码。
// Update rotation this.planeDetails.rotation += drotation * dt; // Use implicit Euler integration to step the simulation forward // Calculate the velocity at the next velocity this.planeDetails.vx += forceX * dt; this.planeDetails.vy += forceY * dt; // Calculate the next position this.planeDetails.x += this.planeDetails.vx * dt; this.planeDetails.y += this.planeDetails.vy * dt;
物理部分完成了。很简单!最美妙的部分是它所显现出来的涌现行为。试试这个游戏吧。在任何时候都没有条件判断说如果飞机移动不够快,就让它失速并坠落。也没有检查说如果飞机在地面上以一定速度移动,它就应该升空。这些涌现特性是上述简单规则的结果——优雅而简洁。显然,这个游戏并不完全真实,飞机轻触按钮就会旋转,这在客机上是不常见的。然而,我自己对它的真实感感到惊喜。
现在,飞机的位置在每个时刻都是已知的。它只需要渲染。
渲染
HTML5 canvas是绘制我们游戏的明显选择。我不会深入探讨使用HTML5 canvas的细节,因为已经有很多很好的教程可以比我解释得更好。
游戏的一个特点是有一个水平和垂直滚动的地图,飞机可以在其中自由移动。Canvas 最强大的功能之一是能够将一个 canvas 绘制到另一个 canvas 上。这允许将复杂的背景渲染到单独的“地图”canvas 上,然后可以通过单个操作将其绘制到主可见 canvas 上。地图的宽度和高度是任意选择的 5000x5000(像素和游戏单位)。地图通过从绿色 -> 浅绿色 -> 深蓝色 -> 浅蓝色的渐变绘制。选择 200 个随机点来绘制云朵。对于每个云点,绘制 400 个小的半透明白色圆圈以创建云效果。这是 80,000 个圆圈,当然不应该每帧都绘制。
(function drawMap() { ctx.rect(0, 0, that.width, that.height); // Create green -> blue gradient var gradient = ctx.createLinearGradient(0, 0, 0, that.height); gradient.addColorStop(0, '#8ED6FF'); gradient.addColorStop(0.95, '#004CB3'); gradient.addColorStop(0.95, '#00aa00'); gradient.addColorStop(1, '#007700'); ctx.fillStyle = gradient; ctx.fill(); // Choose 200 random point to draw clouds at ctx.fillStyle = "#ffffff"; ctx.globalAlpha = 0.03; for(var i=0;i<200;i++) { var cloudYPosition = Math.random() * that.height - 500; var cloudXPosition = Math.random() * that.width; // For each random point, draw some white circles around it to create clouds for(var j=0;j<400;j++) { ctx.beginPath(); ctx.arc(cloudXPosition + 300*Math.random(), cloudYPosition + 100*Math.random(), Math.random() * 70, 0, 2 * Math.PI, false); ctx.fill(); } } })();
地图画布非常大,无法在大多数显示器上显示。在每个渲染步骤中,地图的正确部分被绘制到可见画布上。这允许任何尺寸的设备玩游戏。绘制的部分是一个以飞机为中心的方框。代码还确保可见方框永远不会渲染地图以外的区域。
下面显示了渲染地图正确部分的代码。注意 alpha
参数的使用——这是上面游戏循环部分中描述的值,它允许对飞机的前一个和下一个位置进行插值以实现平滑动画。
// Render the current state of the game this.render = function(alpha) { var oneMinusAlpha = 1 - alpha; // Interpolate the positions based on the alpha value var userX = alpha * userPlane.planeDetails.x + oneMinusAlpha * userPlane.previousDetails.x; var userY = alpha * userPlane.planeDetails.y + oneMinusAlpha * userPlane.previousDetails.y; // Set the position of the camera - it should follow the user's plane var cameraX = userX - canvas.width*0.5; var cameraY = map.height - userY - canvas.height*0.5; // Ensure the camera area remains inside the game area with a border of 100 cameraX = Math.max(100, cameraX); cameraX = Math.min(cameraX, map.width - canvas.width - 100); cameraY = Math.max(0, cameraY); cameraY = Math.min(cameraY, map.height - canvas.height); // Only draw the visible part of the map onto the main canvas ctx.drawImage(map.canvas, cameraX, cameraY, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height);
游戏现在是横向卷轴的,地图不需要完全显示在显示器中。渲染的最后一部分是绘制飞机。为了方便起见,画布的坐标被转换到摄像机坐标。
// Transform the canvas to coordinates to camera coordinates ctx.save(); ctx.translate(-cameraX, -cameraY); ctx.font = '20px Calibri'; ctx.textAlign = 'center'; for(var i=0;i<planes.length;i++) { var plane = planes[i]; // Interpolate the positions based on the alpha value var x = plane.planeDetails.x * alpha + plane.previousDetails.x * oneMinusAlpha; var y = plane.planeDetails.y * alpha + plane.previousDetails.y * oneMinusAlpha; var rotation = plane.planeDetails.rotation * alpha + plane.previousDetails.rotation * oneMinusAlpha; ctx.save(); // Transform to the centre of the plane so that it can be rotated about its centre ctx.translate(x, map.height - y); ctx.fillText(plane.planeDetails.name, 0, -40); ctx.rotate(-rotation); ctx.drawImage(plane.canvas, - plane.halfWidth, - plane.halfHeight); ctx.restore(); } ctx.restore(); }
游戏现在已绘制完成,并且完全可玩。只需少量额外的工作,就可以使其成为多人游戏。
多人游戏
多人游戏功能通过 WebSockets 实现。WebSockets 允许现代浏览器通过套接字直接与服务器进行双向通信,而无需 HTTP 头部的开销。这不是一篇编写服务器的教程,因此为简单起见,服务器仅仅接受消息并将其传递给所有其他连接的客户端。这显然存在安全漏洞,并且很容易作弊(如果游戏有目标的话),但这对于演示来说没问题。我在撰写本文时正处于 JavaScript 的状态,所以我使用 Node.js 作为服务器,代码完全无趣,但可以在随附源代码的 server.js 中找到。通信过程是:
- 游戏刚开始时,一架飞机为自己分配一个随机 ID。
- 每个时间步,飞机将其飞机详细信息连同其 ID 发送给服务器。
- 服务器将消息传递给所有客户端。
- 如果客户端识别出现有 ID,则更新飞机详细信息;否则,创建一架新飞机。
function io(planeCallback) { var ready = false; window.WebSocket = window.WebSocket || window.MozWebSocket; var connection = new WebSocket('ws://192.168.0.6:1337'); connection.onopen = function () { ready = true; }; connection.onmessage = function (message) { try { planeCallback(JSON.parse(message.data)); } catch (e) { console.log('Error processing message', message.data); return; } }; connection.onerror = function (e) { console.log(e); }; this.send = function(plane) { if (!ready) { return; } connection.send(JSON.stringify(plane.planeDetails)); } }
/* * Called when the server receives another plane's details */ function planeDetailsRecieved(planeDetails) { if (planeDetails.id === userPlane.planeDetails.id) { return; } var existingPlane = null; for(var i=0;i<planes.length;i++) { if (planes[i].planeDetails.id === planeDetails.id) { existingPlane = planes[i]; break; } } if (existingPlane == null) { existingPlane = new plane(); existingPlane.previousDetails = planeDetails; planes.push(existingPlane); } existingPlane.planeDetails = planeDetails; }
结论
现代网络技术使创建功能齐全的游戏相对容易。这里展示的游戏尚未最终完成,但它表明,只需最少的代码和努力,就可以创建一款半真实的游戏。
如果您希望更深入地了解代码或试玩游戏,请查阅完整的源代码。要运行游戏,只需打开 index.html。如果您希望启用多人游戏,请在打开游戏之前运行“node server.js”。祝您玩得愉快!