65.9K
CodeProject 正在变化。 阅读更多。
Home

HTML5 多人飞机游戏

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.90/5 (27投票s)

2013年5月28日

CPOL

10分钟阅读

viewsIcon

60229

downloadIcon

2676

关于制作基于物理的多人 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 - 表示飞机可以在其中移动的地图
server.js 也包含在内,它是使用 Node.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 是飞机与地面形成的角度
  • xy 是飞机的 x 和 y 坐标
  • vxvy 是速度的水平和垂直分量
  • thrust 在飞机向前加速时为 true(用户按住向上键)
  • rotateAntiClockwiserotateClockwise 在用户按住左或右键旋转飞机时为 true

底部三个属性需要用户输入,其余属性由模拟更新。任何物理模拟的第一步都是绘制经过验证的力图(或至少在脑海中描绘它)。

Force diagram

图片中的力足以使飞机移动和飞行,但还需要进行一个小小的补充。如果飞机向上俯冲,就像开始一个循环一样,升降舵应该增加升力并将飞机向后推。

重力和推力可以任意设定。升力应与飞机“向前”移动的速度成正比。静止的飞机不产生升力,垂直指向水平移动的飞机也不产生升力。阻力/摩擦力也与飞机的速度成正比,静止的飞机没有摩擦力。

计算飞机受力的代码如下所示。有很多对余弦和正弦的引用——如果您想要某个事物的水平分量,请将其乘以其角度的余弦;同样,要获得垂直分量,请将其乘以其角度的正弦。游戏中的代码不会比这更复杂:

// 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”。祝您玩得愉快!

© . All rights reserved.