浏览器游戏开发完整指南
在我刚开始接触网页开发的时候,偶然发现了HTML Canvas,并立刻被它深深吸引。这一发现促使我创作了许多项目,从绘画程序到照片应用,最终甚至包括游戏。
我经常会重新使用 HTML Canvas,并将其与我新学的技能(例如 Node.js)结合起来。利用这两者,我已经能够创建完整的在线游戏,说实话,自从我第一次接触这项技术以来,我的进步之大让我自己都感到惊讶,如果我没记错的话,我现在应该算是 Canvas 的“行家”了!😌
我觉得是时候分享一下我这些年积累的知识了,而且我觉得这是个好方法。所以,今天我将和大家分享我使用 HTML Canvas 从零开始创建游戏的流程和方法,向大家展示你需要知道的一切,帮助你开始构建和设计自己的游戏!
由于这篇文章篇幅较长,以下是我将要涵盖的所有主题的概述:
先说明一下,我假设你对 JavaScript 和 DOM 有一定的了解,所以在讲解过程中,我不会解释任何语法或基础概念,只会讲解与 HTML Canvas 相关的概念。在本文中,我会解释我个人开发过程中使用的关键概念,最后在最后一章,我会展示一个我创建的简单游戏来演示这些概念。好了,废话不多说,让我们开始吧!😎
什么是HTML Canvas?
HTML Canvas<canvas>元素(也称为Canvas API )是一种特殊的 HTML 元素,用于创建图形,类似于同样用于在 Web 上创建图形的SVG 。但与 SVG 不同的是,HTML Canvas 的设计目标是速度,并支持以编程方式实时渲染图形。
该 API 由元素组成<canvas>,该元素用作图形的容器,并包含大量属性和方法,用于在其上绘制形状、图像、文本、3D 模型等内容,以及对所述元素应用变换。
因为它简单、快速且用途广泛,所以被广泛应用于各种网络应用,例如游戏(2D 和 3D)、视频聊天、动画、绘图程序等等。但在我们开始将其应用到游戏中之前,让我们先来了解一下它的工作原理。
入门
要开始使用 HTML Canvas,我们首先需要将其添加到 HTML 中。该<canvas>元素没有子元素,但可以在其标签之间添加文本,以便在用户的浏览器不支持 Canvas 时显示给用户。
<canvas>Sorry, your browser dosen't support HTML Canvas!</canvas>
默认情况下,该元素的尺寸<canvas>为300x150像素,但可以通过 CSS 调整其大小。请注意,这可能会改变画布内容的宽高比,并且调整大小可能会导致内容拉伸,稍后我会详细介绍这一点。
为了简要介绍 HTML Canvas 的用法(在深入学习之前),我将快速讲解入门所需的最重要方法和属性。不过,在渲染任何内容之前,我们首先需要使用该方法获取 Canvas上下文的引用。canvasElement.getContext()
// get a reference to our canvas's context
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
该canvasElement.getContext()方法接受两个参数。一个是上下文类型,另一个是上下文属性,但上下文属性在我们的例子中无关紧要,可以省略。上下文类型的两个主要值是"2d"和"webgl"。
传递一个"2d"上下文参数指定我们需要一个 2D 渲染上下文,它将canvasElement.getContext()返回一个CanvasRenderingContext2D对象,其中包含用于在画布上渲染 2D 图形的属性和方法。传递另一个上下文参数"webgl"将返回一个WebGLRenderingContext对象,其中包含用于渲染 3D 图形的属性和方法。
WebGL,你可能之前听说过,是一个用于渲染3D图形的JavaScript API,也是在Web上创建3D游戏的热门选择。然而,这个API非常复杂,因此人们通常会选择使用像Three.js这样的库来与之交互。
在本指南中,我们将使用更简单的 2D 渲染上下文,但如果您对 Three.js 或 WebGL 感兴趣,可以查看本文末尾链接的一些资源。
总之,我们来看看一些属性和渲染方法……👇
绘图图形
我们快速浏览一下创建游戏时将用到的主要渲染方法和属性。如果这些属性或方法中有任何不清楚的地方,在本文后面的应用过程中就会变得清晰明了:
-
beginPath():开始一个新的“路径”,这意味着与之前渲染的关联将被切断。该路径包含到目前为止所有已调用的绘图命令。如果您使用 `stroke`rect()和`fill` 绘制一个矩形stroke(),然后使用`fill`rect()和`fill` 填充另一个矩形fill(),那么绘制过的矩形也会被填充,因为这两个矩形都属于同一条路径。因此,当fill()调用 `fill` 时,两个矩形都会被填充。此方法通过开始一个新的路径来防止这种情况发生。稍后您将看到它的实际应用。 -
stroke()用于为当前路径添加描边(或线条)。默认情况下,线条和形状不会被描边,因此必须显式调用此函数。 -
fill()用于填充当前路径。默认情况下不会填充线条和形状,因此必须始终显式调用此函数。 -
moveTo(x,y)将画笔(当前开始绘制的坐标)移动到x坐标y。 -
lineTo(x,y)x:从当前画笔坐标到指定坐标绘制一条线。除非之后使用,y否则该线不会显示。stroke() -
rect(x,y,width,height)绘制一个左上角位于x和处y,长和宽分别为width和的矩形height。除非之后显式调用stroke()或,否则不会显示。fill() -
strokeRect(x,y,width,height)与此相同,rect()但在同一函数调用中绘制矩形的线条(无需stroke()之后调用)。 -
fillRect(x,y,width,height)与 相同strokeRect(),但在同一函数调用中填充矩形,而不是描边。 -
clearRect(x,y,width,height)与清除相同fillRect(),但会清除(擦除)指定区域。这通常用于清除画布,以便绘制下一帧,稍后您会看到。 -
drawImage(image,x,y,width,height):将给定的图像(image)渲染到位于 x 和 y 处、宽度为给定值的画布上width。height通常使用HTMLImageElement通过构造函数创建的作为值。Image()image -
fillText(text,x,y):创建由text`and` 和 `xand`指定的文本y。字体和文本对齐方式等设置可以通过其他属性进行设置,这里我就不赘述了。 -
arc(x,y,radius,startAngle,endAngle,direction)绘制一个以点 (x, y) 为圆心x、y半径为 r 的圆弧,弧度为 θradius,起始角度为 θstartAngle,终止角度为 θendAngle(均以弧度为单位)。要绘制圆,请将 θ 设置startAngle为 0 ,endAngle将 r2*Math.PI设置为0。direction指定圆弧的绘制方向,默认为顺时针。 -
strokeStyle此属性设置描边(线条)的颜色。可以是任何有效的 CSS 颜色值。新的描边颜色将应用于设置后绘制的所有内容。 -
fillStyle设置填充颜色。新的填充颜色将应用于设置后绘制的所有内容。 -
globalAlpha设置不透明度。新的不透明度将应用于设置后绘制的所有内容。
这些是我们将用于在画布上渲染游戏并绘制从背景到角色,甚至是移动用户屏幕控件的每个元素的主要方法和属性。
为了尽量缩短本节内容,我只介绍了一些基本知识。但是,还有很多其他有用的属性和方法可以用来实现各种各样的功能(其中一些我们稍后会看到)。如果您感兴趣,可以在这里进行探索。
如果这些概念难以想象,请不要担心,稍后您会看到它们的实际应用。本指南会随着讲解的深入,解释任何技巧或其他相关信息。现在我们已经了解了用于创建游戏的工具以及一些使用方法,接下来让我们看看实现的第一步。
游戏区域大小
在画布上绘制任何游戏元素之前,我们需要做的第一件事是确定要使用的尺寸,以及游戏区域在不同屏幕尺寸上的显示方式。在展示我的方法之前,我们先来了解一下画布的属性width。height
如前所述,画布的默认尺寸为300x150,但这同时也是画布内容的默认尺寸。我们可以使用 `x`width和 `y`height属性来更改这些内部内容的尺寸,并控制画布在 x 轴x和yy 轴上渲染时使用的单位数。以下是一个示例,并解释了其工作原理:
也可以使用HTML 中的`width`width和`height` 属性一次性设置画布尺寸及其内容尺寸:height
<!-- canvas itself and its content is 300x250 -->
<canvas width="300" height="250">Sorry, your browser dosen't support HTML Canvas!</canvas>
你选择的测量方式将决定你是否以及如何使用这两个属性。现在你已经理解了这个概念,接下来我将向你展示我认为三种最佳且最常用的测量游戏尺寸的方法。
应用静态贴合
“静态适配”(暂且这么称呼)是指将画布尺寸固定为默认值,然后将其放置在网页上的任意位置。通常,这种方式适用于大屏幕和桌面电脑,既能保证其他内容可见,又不会最大化游戏区域;或者,它还能保持游戏区域的宽高比,而无需考虑是否最大化。
对于某些游戏来说,宽高比至关重要,因为如果改变宽高比,游戏内容可能会被挤压或拉伸。一个很好的静态适配例子是网络游戏《坦克大战》(我以前经常玩😊)。你可以看到,他们只是简单地将游戏区域放置在页面上,保持其宽高比,并将其最小化,以确保网页的其他内容可见。
应用这种适配方式时,你需要一次性设置画布的默认内容尺寸,并且之后不要再更改。它们应该与 CSS 中实际的画布尺寸保持相同的宽高比。无论何时需要缩放画布,都要始终保持其宽高比和内部尺寸不变。
应用 Theator Fit
“适配屏幕”或“屏幕模式”是最大化内容尺寸并保持其宽高比的最常用方法之一。这种方法会将内容的高度拉伸到设备的全部高度,并保持宽度比例不变,还可以选择用黑色填充任何空白区域(或者反之,具体取决于客户端设备的尺寸)。
这种方法通常用于游戏区域需要保持特定宽高比,并且希望在不裁剪任何画面内容的情况下尽可能扩大显示范围的情况。我没能在游戏中找到合适的例子,但 YouTube 视频播放器就是一个很好的例子。正如你尝试全屏播放视频时所看到的,为了遮盖视频本身未填满的空白区域,视频播放器的顶部或底部可能会出现黑边。
下面我将举例说明并详细解释如何应用此方法:
注意画布如何随视口变化而调整,这样即使用户调整屏幕大小,游戏内容也不会拉伸或变形,始终呈现最佳效果。与静态适配类似,您应该一次性设置内容尺寸,无需更改,只需调整画布的 CSS 尺寸即可缩放,并保持宽高比。此外,鼓励用户(如果可以)翻转设备,使屏幕尽可能接近游戏区域的理想宽高比,也是明智之举。
应用全屏适配
全屏适配可能很简单,也可能稍微复杂一些。它指的是将画布(及其内容)的尺寸拉伸到用户设备的精确尺寸,使其不留任何缝隙。在使用全屏和剧院模式时,我建议在触摸屏设备上使用锁定画布的方法来实现全屏显示,因为当玩家与屏幕交互玩游戏时,浏览器可能会滚动和移动。HTMLElement.requestFullscreen()
通常只有在游戏区域的尺寸无关紧要,或者不需要显示画布内的全部游戏区域时,才应使用全屏模式。在线游戏Narwhale.io就是一个很好的例子,其中角色位于屏幕中央,地图会自然地移动到视野中。
这样做的一个缺点是,游戏地图在不同客户端上显示的范围可能会略有不同。如果游戏在屏幕尺寸小于平板电脑的手机上绘制,虽然每次渲染都使用相同数量的单位(也就是说,手机上的 2x2 正方形在平板电脑上也是 2x2),但平板电脑会使用更多单位,因为画布内容尺寸会根据其更大的屏幕尺寸进行调整。这意味着,使用大屏幕的用户最终看到的地图范围会比使用小屏幕的用户大得多。
根据你玩的游戏类型,这可能无关紧要,但如果你很在意,我有一个解决方案。我发现解决这个问题的方法是,在调整渲染尺寸时不要使用像素(默认单位),而是根据设备尺寸创建我自己的“视口单位” 。你可以把它想象成在 CSS 中使用“%”单位而不是“px”单位。
我之前玩过一款采用全屏模式的在线游戏,并将这种方法应用到游戏中,结果表明它能非常有效地保持不同设备上的画面比例一致。下面是我截取的两张屏幕截图,对比了游戏区域在我手机和平板电脑屏幕上的大小。
手机游戏区域(720x1520):
平板电脑游戏区域(1920x1200):
注意看,游戏元素在平板电脑屏幕上看起来并没有变小,而且长椅到屏幕边缘的距离也几乎相同。当然,由于屏幕比例略有不同,某些设备不可避免地会比其他设备显示更多或更少的地图内容,但这无需担心。
简而言之,如果您使用像素单位(默认单位)并启用全屏显示,您可能会发现地图在不同设备上的尺寸差异很大(如果您不在意这一点,则无需担心)。但如果您使用“视口”单位,游戏尺寸将保持一致。如果您感兴趣,这里我将提供一个示例并解释如何应用这些百分比单位。
另外,如果您使用的是触摸屏设备,并且有兴趣查看我截图所用的未完成的多人游戏,可以点击这里查看。如果您想了解我是如何应用本文中提到的一些技术的,也可以深入研究代码。
好了,废话不多说,让我们开始构建游戏吧!👇
创建渲染管线
在创建任何角色、物体或背景之前,我们首先需要定义一个顺序和结构,用于渲染和管理这些实体。由于我们是从零开始构建游戏,而 HTML Canvas 没有提供任何框架,所以我们必须自己定义一个结构。我称之为渲染管线。
通常情况下,它看起来会是这样的:
// get canvas 2D context object
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
// object for storing globally accessable states
const GLOBALS = {}
// Array where all props will be stored
const PROPS = [];
// Array where all characters will be stored
const CHARS = [];
// function for applying any initial settings
function init() {
}
// function for rendering background elements
function renderBackground() {
}
// function for rendering prop objects in PROPS
function renderProps() {
}
// function for rendering character objects in CHARS
function renderCharacters() {
}
// function for rendering onscreen controls
function renderControls() {
}
// main function to be run for rendering frames
function startFrames() {
// erase entire canvas
ctx.clearRect(0,0,canvas.width,canvas.height);
// render each type of entity in order, relative to layers
renderBackground();
renderProps();
renderCharacters();
renderControls();
// rerun function (call next frame)
window.requestAnimationFrame(startFrames);
}
init(); // initialize game settings
startFrames(); // start running frames
这为我们提供了一个有序的流程,可以用来绘制游戏的所有元素。流程如下:
-
我们创建一个容器来存放所有需要全局访问的状态。例如,如果我们想创建一个跟随角色移动的“宠物”,我们可以将角色的坐标存储在这个全局容器中,供宠物对象访问并用作跟随的相对位置。
-
我们定义了两个数组,一个用于存储所有道具对象(可移动或可与用户交互的对象),另一个用于存储所有角色对象(由用户控制的对象)。道具和角色对象都将使用类来创建,我们稍后会看到。
-
定义了一个初始化函数,用于设置任何初始状态,例如设置画布的尺寸、颜色、字体等。通常情况下,你会在这里放置初始画布大小的逻辑,就像上一节中讨论的那样,或者注册一个事件监听器,以便在调整大小时调整游戏区域(取决于你偏好的大小调整方法)。
-
我们定义了一个用于渲染背景的函数。在这里,我们可以渲染一整张图片,也可以渲染单独的背景元素(稍后会详细介绍)。
-
我们创建一个函数,用于渲染
PROPS数组中的每个属性。 -
我们创建一个函数,用于渲染
CHARS数组中的每个字符。 -
我们创建了一个函数,该函数将渲染屏幕上的控件(适用于移动设备)、线条按钮和操纵杆,以及任何其他显示内容,例如显示生命数。
-
主函数已创建。调用此函数后,帧将开始运行。它首先使用 `eleather` 函数擦除画布
clearRect(),然后为每种类型的实体调用渲染函数。如果在画布上绘制了某些内容,然后我们又在其上方直接绘制了其他内容,则第一次渲染的内容将被覆盖,这意味着我们需要分层考虑。因此,每种类型的实体的渲染函数都按照逻辑顺序调用(背景 -> 对象 -> 角色 -> 控件),背景位于最底层,屏幕控件位于最顶层。此函数的最后一步是运行一个特殊方法requestAnimationFrame。此方法接收一个函数作为参数,它会在浏览器准备好渲染新内容时立即运行,因此我们将其作为参数传递startFrames。我们在一个循环中调用它,以便帧可以无限期地运行(请注意,这requestAnimationFrame不是阻塞的,不像无限期运行for循环那样会导致页面无响应)。 -
我们调用
init`and`startFrames来初始化游戏,并开始运行帧。
当然,如果您积极跟随操作,您可以根据自己的喜好对这个过程进行个性化调整,因为这只是我个人的过程,而且这一切都不是一成不变的(尽管我仍然建议使用requestAnimationFrame)。
本节旨在说明,你需要为游戏渲染、状态管理、动画制作和用户交互构建某种结构。这是最重要的部分,因为它能防止游戏最终变得混乱不堪、笨拙不堪。
希望到现在大家已经基本掌握了基本概念,既然游戏的框架已经搭建完毕,我们终于可以开始用实际的背景、物体和角色(等等)来填充渲染管线了!
建筑精灵
“精灵”指的是任何可以移动、交互或拥有某种状态或动画的渲染对象或角色。有些精灵可以用对象表示,有些则用函数表示,两者通常都应该存储在单独的文件中,以保持渲染管线所在文件的整洁。我通常将它们分为三类:
- 背景精灵
- 对象精灵
- 角色精灵
正如其名,它们是游戏中实际呈现的元素,每个元素都有不同的用途。下面,我将详细解释这些元素的具体应用以及创建方法。
背景精灵
在为游戏创建背景时(我们稍后会深入探讨),通常有两种选择。背景可以渲染成一张预先创建的图像,也可以渲染成一组“背景精灵”。这可以是多次渲染,也可以是多张图像的集合。
由于背景元素无法交互且不保存任何状态,因此通常将其创建为仅负责渲染精灵的函数,而不是像角色和物体精灵那样创建具有渲染方法和属性的对象(稍后会详细介绍)。这些函数会接收一个像素x和坐标作为参数(也可以选择性地接收任何其他与显示相关的参数),并在渲染管线的函数y内部直接调用。renderBackground()
这是我创作的一个简单花盆精灵图的例子:
这里我使用渲染方法从头开始绘制每一朵花。如果你也这样做,重要的是要根据传递给函数的参数来绘制精灵的每个部分x。y不过我不建议这样做,因为它很繁琐,而且使用预先准备好的图像通常会效果更好。我使用渲染方法只是为了演示它们的用法。
同样,您也可以完全跳过自己构建花朵的所有步骤,直接使用该drawImage(x,y)方法将预先构建的图像或 PNG(希望比我的花朵看起来更好)绘制到画布上作为单个背景精灵,甚至完全跳过所有步骤,直接在函数中渲染单个图像作为整个背景renderBackground()。
对象精灵
对象精灵类似于背景精灵,但它们通常包含某种状态(用于用户交互)、运动或动画,并且可能访问某些全局状态GLOBALS,例如吊扇或用户触摸时会打开和关闭的门。通常,这些对象精灵由类和对象构成,这些类和对象存储在PROPS数组中,然后在renderProps()函数内部遍历数组以渲染其中的每个对象。
对象精灵的类总是会包含一个render()用于渲染精灵的命令方法,该方法会在渲染过程中被访问和调用renderProps。当然,你不必非得这样命名,但你应该为每个属性提供一个标准的渲染方法。在该render()方法内部,状态可以用来影响精灵的显示方式。与背景精灵类似,这些方法也可以接受一个 ` xis` 和 ` yis` 参数,以及任何其他与交互或显示相关的附加参数。下面我创建了一些动画尖刺,作为简单对象精灵的示例:
这种运动之所以有效,是因为我们不断地改变渲染绘制的坐标(也就是我们的状态),而且由于帧是无限循环的,我们对渲染所做的任何更改都会立即反映出来,因为精灵会不断地用新的状态重新绘制,这意味着我们可以控制动画的时机。再次说明,我的尖刺效果相当粗糙,我使用渲染方法只是为了演示它们的用法并验证这个概念。你可以把你的精灵做得尽善尽美。
这是一个很好的例子,展示了哪些类型的元素应该渲染成对象精灵,例如障碍物或角色可以与之交互或受到影响的物体。如图所示,通常你应该将它们创建为一个类,并在其中指定其默认功能并为其添加一个render方法。之后,每当需要放置相同类型的新对象精灵时,只需调用该方法即可,从而保持代码简洁明了,符合 DRY 原则。
角色精灵
角色精灵的功能与对象精灵基本相同,区别在于角色精灵通常具有由键盘控制或游戏手柄等外部因素控制的状态,并且渲染层级高于对象精灵。角色精灵由标准类创建为对象,每个对象都具有状态和render方法,并被添加到CHARS数组中。该数组将被遍历renderCharacters()以调用每个现有角色的渲染方法。这其中既包括玩家的角色,也包括其他玩家的角色,例如在在线游戏中。
虽然它们很相似,但最好将它们分开,因为通常你会希望角色渲染在比物体和背景更高的图层上。
下一节我将展示如何使用角色精灵实现控制,并解释如何在游戏中创建不同类型的动作,例如让角色看向鼠标、制作操纵杆和使用键盘控制。
运动与控制
在本节中,我将解释并演示如何在 2D 游戏中实现常见的控制和角色移动,以及我用来实现这些功能的许多方法,这些方法也可用于创建其他类型的控制和移动。下面我将逐一解释,并提供代码和示例。
实现游戏手柄
摇杆是移动游戏中常用的控制方式,通常用于控制角色动作,主要有两个方面:1)控制角色的角度; 2)控制角色的移动方向。除了显示之外,摇杆的主要作用是记录这两个参数,以便将其应用到它所控制的任何实体上。通常情况下,摇杆会保存角度和x方向y信息,GLOBALS以便所有需要这些信息的角色都能访问到。
这些属性允许我们指定精灵在游戏区域中移动的特定方向以及它们朝向的角度。通常,这些属性用于角色控制,并使用双摇杆,一个用于控制角色的角度,另一个用于控制角色的方向。
下面我将展示一个创建游戏手柄的示例和完整说明。我制作这个示例是为了方便您使用鼠标或触摸屏设备进行交互。请尽量不要过度调整大小,否则可能会破坏示例:
正如你所见,我创建了一个用于创建游戏手柄的类,就像我创建对象精灵一样,然后实例化并渲染它renderControls()。但严格来说,它们并不是精灵,因为游戏手柄本身并不是游戏元素。我只是简单地将其渲染成两个圆圈,一个作为摇杆,一个作为底座(同样,你可以随意设计这些部件的外观)。
我在render方法中添加了逻辑,使摇杆能够朝向用户的手指移动,同时保持在底座内(distance()辅助函数用于测量两点之间的距离)。利用我添加到GLOBALS(angle())中的一个辅助函数,结合底座和摇杆的中心坐标,我还可以确定这两个坐标之间的角度,以及x摇杆y相对于底座的偏移量(公式稍后介绍),这些信息显示在摇杆上方。上述代码片段中注释了所使用的公式。angle()distance()
注意:我用来计算角度的公式会输出弧度,因为
rotate()画布上下文的方法使用的就是弧度单位。稍后会用到这个角度来旋转角色。
这些信息可以保存下来GLOBALS,供我们的主角精灵稍后访问,以控制其移动。另一点需要注意的是,我通过 `setTimeout` 让操纵杆类访问鼠标操作GLOBALS,然后在它自己的类中实现其逻辑,而不是将其放在鼠标/触摸事件的事件监听器中。这样做可以使代码更加清晰有序,避免将每个需要鼠标事件的精灵的逻辑都塞进同一个事件监听器中。这就是为什么init()我在 `setTimeout` 中注册了鼠标/触摸事件监听器,当这些事件触发时,它们会将信息添加到 ` setTimeout` 中,GLOBALS使其可以全局访问。
操纵杆移动
下面展示了如何将操纵杆与角色精灵集成以实现移动。我们的角色精灵是左上角的红色圆圈。只需使用操纵杆,即可用手指或鼠标在屏幕上移动它。再次提醒,请尽量不要调整窗口大小:
这里,我们的角色精灵正在访问 `<Prite>` 上的 `x`charX和 `y`charY属性GLOBALS,这些属性会根据操纵杆的状态不断更新。这样做比直接在操纵杆类中更新角色精灵的坐标要好得多,因为如果我们要添加更多由操纵杆控制的精灵,就必须在操纵杆类中塞入大量代码。而现在,任何精灵都可以通过 `<Prite>` 访问这些信息,GLOBALS并将实现保存在自己的类中。
这种移动方式与实现键盘控制略有不同,因为我们的角色精灵是以非常平滑的角度移动,而不是简单地向上或向左移动。其工作原理如下:首先,我们声明 ` h`charX和charY`v` GLOBALS,它们分别表示角色随时间推移的水平和垂直移动单位数。
注意:摇杆确定这两个值的方法是测量摇杆拇指点到其底座中心的偏移量。具体方法是:用拇指点的坐标减去底座的坐标得到,再用拇指点的坐标减去底座的坐标得到。你可能只想使用这
x两个计算结果的一小部分,比如10%,因为如果计算结果为 -56.09,你的角色会非常迅速地向左移动很多单位。当然,你可以对这两个值分别取不同的分数(只要取相同的分数即可)。关键在于控制角色精灵在一段时间内沿一个方向移动的单位数与沿另一个方向移动的单位数的比率,从而实现动态角度的移动。yxxcharXyycharYcharX
如果用户将摇杆置于右下角(即摇杆在每个方向上的最大行程),这意味着角色会向右下方斜向移动。为什么呢?因为摇杆的宽度和高度相等(因此,当摇杆位于右下角时,其宽度和高度的偏移量也相等),所以摇杆的上下charX和左右偏移量的值相同,角色会以相同的速度向下和向右移动,从而导致角色向右下方斜向移动。希望这能解释清楚,但幸运的是,实现键盘控制要简单十倍。charYxycharXcharY
键盘控制
与游戏手柄控制不同,键盘移动的实现要简单得多。只需在 `setState` 中注册一个 `getState`keydown和 ` getState`keyup事件init(),然后跟踪监听的键码是否被按下或释放即可。该信息保存在 ` GLOBALSsetState` 中,角色精灵可以轻松访问它。根据当前键是否被按下,角色精灵的 `x`x和y`y` 坐标将按一定值(角色精灵的 `x`speed属性)递增或递减。
下面是一个角色精灵(红色圆圈)的示例,您可以使用方向键控制它:
很简单吧?顾名思义,speed角色精灵的移动属性控制着角色每次移动的单位数,从而控制角色每次移动的距离。使用摇杆控制也可以实现同样的效果:通过乘以或除以相同的系数来charX加快移动速度,或者通过除以相同的系数charY来减慢移动速度。
角色轮换
在下面的示例中,我使用该方法创建了一个小型火箭角色精灵drawImage()。它不仅会沿着操纵杆的方向移动,还会模拟操纵杆的角度。操纵杆的角度存储在一个变量中GLOBALS.roation,我们的角色精灵可以轻松访问该变量并旋转相应的角度。
下面给出一个示例,并解释如何对角色精灵应用旋转:
这里的关键在于angle()`on` 方法GLOBALS,它接收两组坐标并返回它们之间的角度(以弧度为单位)。`on`的公式为atan2(y2 - y1, x2 - x1)` 。我们用它angle()来测量摇杆拇指中心与摇杆底座中心之间的角度,然后将结果赋值给GLOBALS.rotation`。` ...
实现此功能的第二个关键部分是rotate()画布上下文的方法,它用于将旋转应用于角色精灵。该方法的工作原理是将画布从左上角旋转指定的弧度数。旋转仅应用于方法调用后绘制的所有内容。当然,这与您预期的可能大相径庭,但我们可以通过以下方式进行简化:
我们希望围绕要旋转的对象(角色精灵)的中心点而不是左上角来旋转画布。实现方法是:首先使用方法
translate(x,y)将画布的左上角移动到要旋转对象的中心坐标,然后使用旋转方法旋转所需的角度,接着使用rotate(radians)撤销方法撤销平移translate(-x,-y),使用应用了旋转的画布重新绘制精灵,最后调用重置方法resetTransform()重置刚刚进行的画布变换,这样在要旋转的对象之后绘制的所有内容就不会受到旋转或平移的影响。这个过程可以在角色精灵的渲染方法中看到。
您可以在这里rotate()阅读更多关于translate()方法的信息。
静态运动与动态运动
在2D游戏中,通常存在两种主要的角色移动方式:
-
静态移动:地图固定在游戏区域内,角色相对于地图移动。例如《坦克大战》系列游戏。
-
动态移动:在这种模式下,角色固定在屏幕中心,地图相对于角色移动。例如Narwhale.io游戏。
到目前为止,我举例说明的运动类型都是静态运动,但在本节中,我将举例说明如何应用动态运动:
注意观察“摄像机”如何随着地图相对于角色的移动而跟随移动。我们通过将操纵杆的偏移量应用到地图锚点坐标上来实现这一点x,y背景精灵将相对于该锚点绘制(当然,还要从主角精灵中移除 ` charXand` 的实现)。charY
首先,我将摇杆的偏移量存储在`x`x和`y`中,然后声明 `x`来存储“地图锚点”坐标。在渲染每个精灵之前,我通过从`x` 中减去 `y`和 `y` 来更新 `x`和`y` ,并从 `y` 中减去 `y`。yGLOBALS.anchorXGLOBALS.anchorYGLOBALS.mapAnchorrenderBackground()GLOBALS.mapAnchor.xGLOBALS.mapAnchor.y GLOBALS.anchorXGLOBALS.mapAnchor.x GLOBALS.anchorYGLOBALS.mapAnchor.y
这里我们用减法代替静态移动中的加法,因为地图的移动方向必须与角色移动的方向相反。最后,在Pot()精灵内部,我将GLOBALS.mapAnchor.x和GLOBALS.mapAnchor.y分别加到它的x和y坐标上,以便相对于当前地图锚点坐标绘制精灵。
碰撞检测
碰撞检测在游戏中通常至关重要,无论是阻止角色穿墙而过,还是在子弹击中角色时将其杀死。
在本节中,我将简要介绍碰撞检测的基本原理,并解释如何检测正方形、圆形和矩形之间的碰撞,并给出示例。
正方形和长方形
碰撞检测的核心在于利用两个形状的尺寸和坐标,通过数学方法判断它们是否接触。根据形状本身(例如正方形、圆形等),判断方法也会有所不同。
下面我将举例说明如何对正方形进行碰撞检测(同样的方法也适用于任意大小的矩形)。只需将粉色正方形拖入蓝色正方形,当粉色正方形与蓝色正方形接触时,蓝色正方形就会变成红色,如下例所示:
我做的第一件事是实现拖动粉色方块的逻辑。这部分不太重要,但如果你对我的实现方式感兴趣,可以看看代码。接下来,在方块的基类(也就是蓝色方块实例化的那个类)中,我在构造函数里添加了一个名为 `.` 的对象collisions。在这个对象上,我们会为每个需要检测碰撞的精灵创建一个子对象,因此,我添加了 ` pinkSquare.`
在pinksquare(以及所有其他碰撞对象上collisions)都存在一个conditions属性,用于存储一个布尔值,该值以数学方式确定与粉色方块的接触情况(我们稍后会深入探讨),此外还有一个inContact属性用于指定精灵是否与方块接触,不过这里我们并没有使用这个属性。这是实现碰撞检测时一个很好的标准,因为它简单明了,而且能保持代码的有序性。
注意:
collisions.pinkSquare.conditions是一个字符串而不是布尔值,因此我们可以用 不断重新评估它eval(),而不必将其硬编码到if语句中。
以下是用于判断两个矩形/正方形之间是否发生碰撞的伪代码,其结构与布尔值完全相同collisions.pinkSquare.conditions:
A = {x: ... y: ..., width: ..., height: ...}
B = {x: ..., y: ..., width: ..., height: ...}
if (A.x + A.width) > B.x
and A.x < (B.x + B.width)
and (A.y + A.height) > B.y
and A.y < (B.y + B.height)
...
阅读本文时,试着想象一下这个布尔运算是如何运作的,最终你就会明白它的要点。
界
判断两个圆(不是椭圆,我们稍后会讲到)是否相切非常简单易懂。为了方便理解,我假设上面例子中的形状现在变成了两个圆。collisions.pinkCircle.conditions判断这两个圆是否相切的伪代码如下:
A = {x: ..., y: ..., radius: ...}
B = {x: ..., y: ..., radius: ...}
if
distance between A and B < (A.radius + B.radius)
...
这里,“A点到B点的距离”指的是两个圆心之间的距离。如果你还记得的话,可以用这个简单易懂的公式来计算:√(x2 − x1)^2 + (y2 − y1)^2。很简单,对吧?
这两个例子都很简单,但对于更复杂、不规则的形状,碰撞检测可能需要更高级的碰撞判定方法。当然,这里我只介绍了基础知识,但如果您计划实现复杂形状的碰撞检测,我强烈建议您参考以下资源:
- https://www.gamedeveloper.com/programming/advanced-collision-detection-techniques
- https://www.toptal.com/game/video-game-physics-part-ii-collision-detection-for-solid-objects
活动
追踪某些事件,例如游戏中门何时打开或角色何时受到攻击,至关重要。这使我们能够全局监听(并触发)游戏中发生的特定事件,而无需考虑当前所在的精灵。
alert()下面我修改了前面的示例,使其使用事件在每次方块接触时运行两次:
基本上,我实现事件的方式与 DOM 事件的实现方式完全相同,即在 `<div>` 元素中添加 ` addEventListener()and`dispatchEvent()方法GLOBALS。这样,这两个方法都可以全局访问。
GLOBALS它还包含一个events属性,该属性的每个子属性都是一个现有事件的名称,指向一个存储该事件回调函数的数组。我只需调用 `add_callback` 函数即可将GLOBALS.addEventListener(name,callback)指定事件的回调函数添加到数组中,然后调用 `callback` 函数dispatchEvent(name)即可调用数组中指定事件名称的每个回调函数。当然,我们也可以通过向 `add_callback` 函数添加新属性来创建新事件GLOBALS.events。是不是很简单?🍋
尽管这很简单,但我还是想澄清一下,在你的游戏中实现某种事件系统非常重要,否则你将不得不硬编码很多东西,这不是一个好的做法。
综合起来
最后,让我逐一介绍构成这一过程的各个关键概念。如果之前我的解释有任何不清楚的地方,我建议您尝试查看代码中每个概念的具体实现方式,以便更好地理解其运作原理。我们先来回顾一下这个过程的每个步骤:
- 选择游戏区域的尺寸(应用到
init()) - 创建渲染管线
- 将精灵填充到渲染管线中(根据精灵的类型,将其放置在各自的位置)。
- 应用移动和控制
- 应用碰撞检测
- 整合事件
这些概念都可以在我制作的这款名为“漂移”的简单游戏中得到体现。游戏的目标是在不被障碍物撞击或离开地图的情况下尽可能长时间地行驶,玩家可以通过点击游戏区域的左侧或右侧,或者使用方向键来控制车辆。我发现这款游戏和玩《Flappy Bird》一样令人沮丧,我的最高分只有33分。
这款游戏很好地诠释了我通篇解释的过程和方法,希望通过本指南和示例,将所有内容整合起来,您将掌握使用 HTML Canvas 和我在本文中介绍的技术来构建自己的游戏的知识。
希望这篇文章对你有所帮助,如果你还想了解更多游戏开发相关的内容,请务必查看以下资源。
感谢阅读,祝您编程愉快!👍
- HTML Canvas API - https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API
- 高级碰撞检测 - https://www.gamedeveloper.com/programming/advanced-collision-detection-techniques
- https://docs.microsoft.com/en-us/archive/msdn-magazine/2015/march/game-development-a-web-game-in-an-hour

