JavaScript游戏教程-使用HTML Canvas和JavaScript构建一个Stick Hero克隆版
在这个教程中,您将学习如何使用纯JavaScript和HTML画布创建受Stick Hero启发的游戏我们将重新创造由KetchApp发行的移动游戏Stick Hero [https//apps.apple.com/us/app/stick-hero/id918338898]我们将讨论游戏的一般情况,以及如何使用JavaScript来实现
在本教程中,您将学习如何使用纯JavaScript和HTML画布创建一个受《Stick Hero》启发的游戏。
我们将重新创建KetchApp发布的移动游戏Stick Hero。我们将介绍游戏的工作原理,如何使用JavaScript在<canvas>
元素上绘图,如何添加游戏逻辑和动画以及事件处理的工作原理。
通过本指南结束时,您将使用纯JavaScript构建整个游戏。
在整个教程中,我们将使用JavaScript来操作游戏状态,使用HTML画布元素来渲染游戏场景。要充分利用本教程,您应该对JavaScript有基本的了解。但即使您是初学者,您仍然可以跟随教程学习。
让我们开始使用JavaScript和HTML画布来构建自己的Stick Hero游戏!
如果您更喜欢视频格式,您也可以在YouTube上观看本教程。
目录
Stick Hero游戏
在这个游戏中,你控制一个英雄,通过拉伸作为桥梁的杆子从一个平台走到另一个平台。如果杆子的长度适合,英雄就可以安全地过到下一个平台上。但如果杆子太短或太长,英雄就会掉下来。
您可以在CodePen上找到我们即将创建的游戏的可玩版本,您还可以查看最终的源代码。在我们详细介绍之前,请试玩一下。
游戏阶段
游戏有五个不同的阶段,这些阶段循环重复,直到英雄掉下来。
- 最初,游戏处于等待用户输入的状态,没有任何动作发生。
- 然后,一旦玩家按住鼠标,游戏就会拉伸一根杆子向上,直到释放鼠标为止。
- 然后,一旦释放鼠标,杆子开始转动并倒下,希望能落在下一个平台上。
- 如果成功,英雄就沿着杆子走到下一个平台上。
- 最后,一旦英雄到达下一个平台,整个场景向左过渡,以使英雄和前方的下一个平台居中。然后整个循环从头开始。游戏等待用户输入,一旦玩家按住鼠标,就会绘制一根新的杆子。
在较不理想的情况下,相同的阶段会相继发生,但在行走阶段中,如果杆子的另一端没有落在下一个平台上,英雄只会走到杆子的边缘,然后掉下来。
游戏的主要部分
我们如何将其转化为代码?这个游戏基本上有三个部分。游戏状态,draw
函数,和animate
函数。
我们有一个游戏状态,它是一系列定义游戏的变量的集合。它包括当前阶段(current phase),英雄的位置(position of the hero),平台的坐标(coordinates of the platforms),棍子的大小和旋转等等。
let phase = "waiting"; // waiting | stretching | turning | walking | transitioning | fallinglet lastTimestamp; // 上一个动画周期的时间戳let heroX; // 前进时改变let heroY; // 落下时改变let sceneOffset; // 移动整个游戏let platforms = [];let sticks = [];let score = 0;...
然后我们会有两个主要的函数:一个根据这个状态在屏幕上绘制场景(这将是draw
函数),并且一个逐渐改变这个状态以使其看起来像动画的函数(这将是animate
函数)。最后,我们还将有事件处理程序来启动动画循环。
如何初始化游戏
首先,让我们用简单的HTML、CSS和JavaScript文件初始化项目。我们将建立代码的大纲,然后初始化游戏的状态。
HTML部分
这个游戏的HTML部分非常简单。大部分游戏将生活在<canvas>
元素内。我们将使用JavaScript在这个画布上绘制。我们还有一个显示分数和重新开始按钮的div元素。
在头部,我们还加载了我们的CSS和JavaScript文件。注意在加载脚本时使用了defer
标记。这将在HTML的其余部分加载后才执行脚本,这样我们就可以立即在脚本中访问HTML的部分(如画布元素)。
<!DOCTYPE html><html> <head> <title>Stick Hero</title> <link rel="stylesheet" href="index.css" /> <script src="index.js" defer></script> </head> <body> <div class="container"> <canvas id="game" width="375" height="375"></canvas> <div id="score"></div> <button id="restart">重新开始</button> </div> </body></html>
CSS部分
CSS部分也不包含太多内容。我们在画布元素上绘制游戏,而画布元素的内容不能通过CSS进行样式设置。在这里,我们只设置了画布、分数元素和重置按钮的位置。
请注意,默认情况下重置按钮是不可见的。我们将使用JavaScript在游戏结束时将其设置为可见。
html,body { height: 100%;}body,.container { display: flex; justify-content: center; align-items: center;}.container { position: relative; font-family: Helvetica;}canvas { border: 1px solid;}#score { position: absolute; top: 30px; right: 30px; font-size: 2em; font-weight: 900;}#restart { position: absolute; display: none;}
JavaScript文件的大纲
最后,JavaScript部分是整个魔法所在的地方。为了简便起见,我把一切都放在了一个文件中,但你可以随意将其拆分为多个文件。
我们将引入几个变量和几个函数,但这是这个文件的大纲。以下内容已包含在其中:
- 我们定义了一些变量,共同组成
game state
。有关它们的值,我们将在初始化状态的部分进行介绍。 - 我们将定义一些变量作为
configuration
,例如平台的大小和英雄的移动速度。我们将在绘制部分和主循环中进行介绍。 - 引用HTML中的
<canvas>
元素,并获取它的绘图上下文。这将由draw
函数使用。 - 引用HTML中的
score
元素和restart
按钮。每当英雄穿越到一个新的平台上时,我们将更新分数。并在游戏结束时显示重置按钮。 - 我们通过调用
resetGame
函数来初始化游戏状态并绘制场景。这是唯一的顶级函数调用。 - 我们定义了
draw
函数,它将根据状态在画布元素上绘制场景。 - 我们设置了
mousedown
和mouseup
事件的事件处理程序。 - 我们定义了
animate
函数,它将操作状态。 - 我们还有一些我们将在后面讨论的实用函数。
// 游戏状态
let phase = "waiting"; // waiting | stretching | turning | walking | transitioning | falling
let lastTimestamp; // 上一次动画周期的时间戳
let heroX; // 前进时改变
let heroY; // 掉落时改变
let sceneOffset; // 整个游戏移动
let platforms = []; // 平台数组
let sticks = []; // 棍子数组
let score = 0; // 分数
// 配置...
// 获取画布元素
const canvas = document.getElementById("game");
// 获取绘制上下文
const ctx = canvas.getContext("2d");
// 其他UI元素
const scoreElement = document.getElementById("score");
const restartButton = document.getElementById("restart");
// 开始游戏
resetGame();
// 重置游戏状态和布局
function resetGame() {
...
draw();
}
function draw() {
...
}
// 鼠标按下事件
window.addEventListener("mousedown", function (event) {
...
});
// 鼠标释放事件
window.addEventListener("mouseup", function (event) {
...
});
function animate(timestamp) {
...
}
如何初始化游戏状态
要开始游戏,我们调用与重置游戏相同的函数-resetGame
函数。它初始化/重置游戏的状态并调用绘制函数绘制场景。
游戏状态包括以下变量:
phase
:游戏的当前阶段。初始值为waiting。lastTimestamp
:由animate
函数使用,用于确定自上次动画周期以来经过了多长时间。我们稍后会更详细地介绍它。platforms
:包含每个平台元数据的数组。每个平台由一个具有x
和w
属性的对象表示,分别表示它们的X位置和宽度。第一个平台始终相同-在这里定义了一个合理的大小和位置,以确保有一个可接受的大小和位置。随着游戏的进展,越来越多的平台会动态生成。heroX
:主角的X位置。默认情况下,主角靠近第一个平台的边缘。在行走阶段,这个值会改变。heroY
:主角的Y位置。默认为0。只有在主角掉落时才会改变。sceneOffset
:当主角前进时,我们需要将整个画面向后移动,以保持主角在屏幕中央。否则主角会走出屏幕。在这个变量中,我们记录需要将整个画面向后移动多少。我们将在过渡阶段更新此值。默认情况下,它的值为0。sticks
:棍子的元数据。虽然主角一次只能伸展一根棍子,但我们还需要存储以前的棍子以便能够渲染它们。因此,sticks
变量也是一个数组。每根棍子由一个具有x
、length
和rotation
属性的对象表示。x
属性表示棍子的起始位置,它始终与相应平台的右上角匹配。它的length
属性将在伸展阶段增长,rotation
属性将在转向阶段从0变为90,或在掉落阶段从90变为180。最初,sticks
数组中有一根‘看不见’的棍子,长度为0。每当主角到达新平台时,都会向数组中添加一根新的棍子。score
:游戏得分,显示主角到达的平台数。默认为0。
function resetGame() {
// 重置游戏状态
phase = "waiting";
lastTimestamp = undefined;
// 第一个平台始终相同
platforms = [{ x: 50, w: 50 }];
generatePlatform();
generatePlatform();
generatePlatform();
generatePlatform();
// 初始化主角位置
heroX = platforms[0].x + platforms[0].w - 30; // 主角靠近边缘
heroY = 0;
// 画面需要向后移动多少
sceneOffset = 0;
// 总是有一根棍子,即使它看起来是不可见的(长度为0)
sticks = [
{ x: platforms[0].x + platforms[0].w, length: 0, rotation: 0 },
];
// 分数
score = 0;
// 重置UI
restartButton.style.display = "none"; // 隐藏重置按钮
scoreElement.innerText = score; // 重置分数显示
draw();
}
在这个函数的结尾,我们还通过确保重置按钮隐藏和分数显示为0来重置UI。
一旦我们初始化游戏状态并重置UI,resetGame
函数调用draw
函数首次绘制屏幕。
resetGame
函数调用一个生成随机平台的实用函数。在这个函数中,我们定义两个平台之间的最小距离(minumumGap
)和最大距离(maximumGap
)。我们还定义平台的最小宽度和最大宽度。
根据这些范围和现有的平台,我们生成一个新平台的元数据。
function generatePlatform() { const minimumGap = 40; const maximumGap = 200; const minimumWidth = 20; const maximumWidth = 100; // X coordinate of the right edge of the furthest platform const lastPlatform = platforms[platforms.length - 1]; let furthestX = lastPlatform.x + lastPlatform.w; const x = furthestX + minimumGap + Math.floor(Math.random() * (maximumGap - minimumGap)); const w = minimumWidth + Math.floor(Math.random() * (maximumWidth - minimumWidth)); platforms.push({ x, w });}
绘制函数
draw
函数基于状态绘制整个画布。它通过偏移移动整个UI,放置英雄的位置,并绘制平台和杆。
与文章开头链接的工作示例相比,这里我们只介绍绘制函数的简化版本。我们不涵盖绘制背景,并简化英雄的外观。
我们将使用这个函数来绘制初始场景和主要动画循环中的所有绘制。
对于初始绘制,这里介绍的一些功能是不必要的。例如,场景上还没有任何杆子。但我们仍然介绍它们,因为这样我们就不必在开始动画状态后重写这个函数。
我们在HTML中定义了一个<canvas>
元素。但是我们如何在其上绘制东西呢?首先,在JavaScript中,我们在文件的开头获取画布元素,然后获取其上下文。然后我们可以使用这个上下文执行绘制命令。
我们还提前定义了一些变量作为配置。这样做是因为我们需要在游戏的不同部分使用这些值,并且我们希望保持一致性。
canvasWidth
和canvasHeight
表示HTML中画布元素的尺寸。它们必须与我们在HTML中设置的尺寸相匹配。我们在各个地方使用这些值。platformHeight
表示平台的高度。我们在绘制平台本身时使用这些值,也在定位英雄和杆子时使用。
绘制函数每次都从头开始重新绘制整个屏幕。首先,让我们确保它是空的。通过在绘制上下文上调用带有正确参数的clearRect
函数,可以确保擦除其中的所有内容。
...<div class="container"> <canvas id="game" width="375" height="375"></canvas> <div id="score"></div> <button id="restart">重新开始</button></div>...
...// 获取画布元素const canvas = document.getElementById("game");// 获取绘制上下文const ctx = canvas.getContext("2d");...// 配置const canvasWidth = 375;const canvasHeight = 375;const platformHeight = 100;...function draw() { ctx.clearRect(0, 0, canvasWidth, canvasHeight); ...}...
如何设置场景框架
我们还希望确保场景的框架正确。当我们使用画布时,我们有一个以屏幕左上角为中心的坐标系统,从右向下增长。在HTML中,我们将宽度和高度属性都设置为375像素。
最初,0, 0坐标位于屏幕的左上角,但随着英雄向前移动,整个场景应向左移动。否则,我们将超出屏幕。
随着游戏的进行,我们更新sceneOffset
的值以跟踪主循环中的移动。我们可以使用这个变量来转换整个布局。我们调用translate
命令来在X轴上移动场景。
function draw() { ctx.clearRect(0, 0, canvasWidth, canvasHeight); // 保存当前的变换 ctx.save(); // 移动视图 ctx.translate(-sceneOffset, 0); // 绘制场景 drawPlatforms(); drawHero(); drawSticks(); // 恢复最近一次保存的变换 ctx.restore();}
重要的是,在我们绘制画布上的任何内容之前进行这样的操作,因为translate
命令实际上并不在画布上移动任何东西。我们在画布上绘制的任何东西将保持不变。
相反,translate
命令会移动坐标系。0, 0坐标将不再位于左上角,而是在左侧的屏幕外。我们绘制的任何东西将根据这个新的坐标系进行绘制。
这正是我们想要的。随着游戏的进行,英雄的X坐标将增加。通过向后移动坐标系,我们确保它将在屏幕内绘制。
translate
命令是可累积的。这意味着如果我们调用了两次translate
命令,第二次不仅会覆盖第一次,还会在第一次命令的基础上添加一个偏移。
我们将在循环中调用draw
函数,所以每次绘制时重置这个变换是很重要的。此外,我们总是以左上角的0, 0坐标开始。否则,坐标系将被无限向左移动。
我们可以通过调用restore
命令来恢复变换,一旦我们不再需要处于这个移动坐标系中。restore
命令将重置转换和许多其他设置为画布在最后一次save
命令时的状态。这就是为什么我们经常通过保存上下文开始一个绘图块,然后通过恢复上下文来结束它的原因。
如何绘制平台
所以刚才我们只是进行了一些铺垫,但我们还没有绘制任何东西。让我们从简单的绘制平台开始。平台的元数据存储在platforms
数组中。它包含平台的起始位置和宽度。
我们可以遍历这个数组,并设置起始位置、平台的宽度和高度来填充一个矩形。通过调用fillRect
函数并传入矩形的X、Y坐标以及宽度和高度来实现这一点。注意Y坐标是倒过来的,从上到下增长。
// 平台的示例状态let platforms = [ { x: 50, w: 50 }, { x: 90, w: 30 },];...function drawPlatforms() { platforms.forEach(({ x, w }) => { // 绘制平台 ctx.fillStyle = "black"; ctx.fillRect(x, canvasHeight - platformHeight, w, platformHeight); });}
关于canvas,或者至少对我来说,有趣的是,一旦你在画布上绘制了某个东西,就无法修改它。就像你绘制了一个矩形,然后你可以改变它的颜色一样。一旦某样东西出现在画布上,它就会保持原样。
就像真正的画布一样,一旦你画了什么东西,你可以用其他东西覆盖它,或者试图清除画布。但是你不能真正改变现有的部分。这就是为什么我们在这里提前设置颜色,而不是之后(使用fillStyle
属性)。
如何绘制英雄
我们不会在本教程中详细介绍英雄部分,但是你可以在CodePen上找到上述演示的源代码。使用canvas元素绘制更复杂的形状有点复杂,我将在未来的教程中详细介绍绘图。
现在,让我们简单地使用一个红色矩形作为英雄的占位符。同样,我们使用fillRect
函数,并传递英雄的X、Y坐标以及英雄的宽度和高度。
X和Y的位置将基于英雄的X和Y状态。英雄的X位置相对于坐标系统,但其Y位置相对于平台的顶部(一旦在平台顶部,其值为0)。我们需要将Y位置调整为在平台顶部。
function drawHero() { const heroWidth = 20; const heroHeight = 30; ctx.fillStyle = "red"; ctx.fillRect( heroX, heroY + canvasHeight - platformHeight - heroHeight, heroWidth, heroHeight );}
如何绘制棍子
接下来让我们看看如何绘制棍子。棍子比较棘手,因为它们可以被旋转。
棍子以与平台类似的方式存储在数组中,但具有不同的属性。它们都有一个起始位置、长度和旋转。最后两个属性会在主游戏循环中发生变化,而第一个属性 – 位置 – 应该与一个平台的右上角匹配。
基于长度和旋转,我们可以使用一些三角函数来计算棍子的终点位置。但如果我们再次转换坐标系统,会更有趣。
我们可以再次使用translate
命令,将坐标系统的中心设置为平台的边缘。然后,我们可以使用rotate
命令围绕这个新的中心旋转坐标系统。
// 棍子的示例状态let sticks = [ { x: 100, length: 50, rotation: 60 }];...function drawSticks() { sticks.forEach((stick) => { ctx.save(); // 将基准点移动到棍子的起始位置并旋转 ctx.translate(stick.x, canvasHeight - platformHeight); ctx.rotate((Math.PI / 180) * stick.rotation); // 绘制棍子 ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, -stick.length); ctx.stroke(); // 恢复转换 ctx.restore(); });}
在translate
和rotate
命令之后,棍子的起始点将在0,0坐标处,而坐标系统将被旋转。
在这个例子中,我们沿Y轴画一条向上的线 – 它的起始点和终点具有相同的X坐标。只有Y坐标在变化。然而,由于整个坐标系统已经旋转,所以这条线朝右移动。现在向上是一个对角线方向。这有点令人费解,但你会习惯的。
线条的实际绘制也很有意思。没有简单的线条绘制命令,所以我们必须绘制一个路径。
我们通过连接多个点来创建路径。我们可以通过弧线、曲线和直线连接它们。在这种情况下,我们只是简单地开始一个路径(beginPath
),移动到一个坐标(moveTo
),然后绘制一条直线到下一个坐标(lineTo
)。然后我们用stroke
命令结束绘制。
我们还可以使用填充命令来完成路径,但这只在绘制形状时才有意义。
请注意,因为我们在这里再次移动和旋转坐标系,在此函数结束时,我们需要恢复变换(并在函数开头保存变换矩阵)。否则,所有即将绘制的命令都会变得扭曲。
事件处理
现在我们已经绘制了场景,让我们通过处理用户交互来启动游戏。处理事件是游戏中最简单的部分。我们监听mousedown
和mouseup
事件,并处理重新开始按钮的click
事件。
一旦用户按住鼠标,我们通过将phase
变量设置为stretching
来启动拉伸阶段。我们重置主事件循环将要使用的时间戳(稍后我们会回到这个问题),并通过请求动画帧调用animate
函数来触发主事件循环。
所有这些只会在游戏的当前状态为等待时发生。在其他任何情况下,我们都会忽略mousedown
事件。
let phase = "waiting";let lastTimestamp;...const restartButton = document.getElementById("restart");...window.addEventListener("mousedown", function () { if (phase == "waiting") { phase = "stretching"; lastTimestamp = undefined; window.requestAnimationFrame(animate); }});window.addEventListener("mouseup", function () { if (phase == "stretching") { phase = "turning"; }});restartButton.addEventListener("click", function (event) { resetGame(); restartButton.style.display = "none";});...
处理mouseup
事件更加简单。如果我们当前正在拉伸棍子,那么当棍子掉落时,我们停止拉伸并进入下一个阶段。
最后,我们还为重新开始按钮添加了事件处理程序。重置按钮默认是隐藏的,只有在英雄跌倒后才会可见。但是我们已经可以定义它的行为,一旦它显示出来,它就会起作用。如果我们点击重置,我们调用resetGame
函数来重置游戏并隐藏按钮。
这就是我们的事件处理程序。其余的就取决于我们刚刚用requestAnimationFrame
调用的主动画循环。
主动画循环
主循环是游戏中最复杂的部分。这是一个将不断改变游戏状态并调用draw
函数根据该状态重新绘制整个屏幕的函数。
由于它每秒将被调用60次,屏幕的不断重绘将使其看起来像是连续的动画。由于该函数运行频率如此之高,我们每次只稍微改变游戏状态。
这个animate
函数作为mousedown
事件的一个requestAnimationFrame
调用而触发(见上文)。通过它的最后一行,它会不断地调用自身,直到我们通过从函数中返回来停止它。
只有两种情况下我们会停止循环:当进入waiting
阶段且没有需要动画的内容时,或者英雄跌倒且游戏结束。
这个函数会跟踪自上次调用以来经过了多少时间。我们将使用这些信息来精确计算状态应该如何变化。就像英雄走路时,我们需要根据它的速度和自上次动画周期以来经过的时间来准确计算它移动了多少像素。
let lastTimestamp;...function animate(timestamp) { if (!lastTimestamp) { // 第一个周期 lastTimestamp = timestamp; window.requestAnimationFrame(animate); return; } let timePassed = timestamp - lastTimestamp; switch (phase) { case "waiting": return; // 停止循环 case "stretching": { sticks[sticks.length - 1].length += timePassed / stretchingSpeed; break; } case "turning": { sticks[sticks.length - 1].rotation += timePassed / turningSpeed; ... break; } case "walking": { heroX += timePassed / walkingSpeed; ... break; } case "transitioning": { sceneOffset += timePassed / transitioningSpeed; ... break; } case "falling": { heroY += timePassed / fallingSpeed; ... break; } } draw(); lastTimestamp = timestamp; window.requestAnimationFrame(animate);}
如何计算两次渲染之间经过的时间
使用requestAnimationFrame
函数调用的函数将接收当前的timestamp
作为参数。在每个循环结束时,我们将这个timestamp
值保存到lastTimestamp
属性中,以便在下一个循环中计算两个循环之间经过了多长时间。在上面的代码中,这就是timePassed
变量。
第一个循环是一个例外情况,因为在那个时候,我们还没有之前的循环。最初,lastTimestamp
的值是undefined
。在这种情况下,我们跳过一个渲染,并且只在第二个循环中渲染场景,那时我们已经拥有了所需的所有值。这就是animate
函数开头部分。
如何动态地改变状态的一部分
在每个阶段,我们都会对状态的不同部分进行动画处理,只有等待阶段是例外,因为在那个阶段我们没有任何需要动画处理的内容。在这种情况下,我们会在函数中返回。这将中断循环,动画将停止。
在拉伸阶段(当玩家按住鼠标时),我们需要随着时间的推移增加杆的长度。我们根据经过的时间和一个定义了杆每增长一个像素所需时间的速度值来计算应该增长多长。
在其他每个阶段中,也发生了非常类似的事情。在旋转阶段,我们根据经过的时间改变杆的旋转角度。在行走阶段,我们根据时间改变英雄的水平位置。在过渡阶段,我们改变整个场景的偏移值。在掉落阶段,我们改变英雄的垂直位置。
每个阶段都有自己的速度配置。这些值告诉我们增加一个像素所需的毫秒数,旋转一个角度所需的毫秒数,走过一个像素的时间等等。
// 配置const stretchingSpeed = 4; // 绘制一个像素所需的毫秒数const turningSpeed = 4; // 旋转一个角度所需的毫秒数const walkingSpeed = 4;const transitioningSpeed = 2;const fallingSpeed = 2;...
如何进入下一个阶段
在大多数阶段中,我们还有一个阈值,用于结束该阶段并触发下一个阶段。等待阶段和拉伸阶段是例外,因为它们的结束是基于用户的交互。等待阶段会在mousedown
事件发生时结束,而拉伸阶段则在mouseup
事件发生时结束。
在杆完全落下且旋转角度达到90度时,旋转阶段停止。行走阶段会在英雄到达下一个平台的边缘或杆的末端时结束。以此类推。
如果达到了这些阈值,主游戏循环将将游戏设置为下一个阶段,然后在下一个循环中进行相应的处理。让我们更详细地看看这些。
等待阶段
如果我们处于等待阶段且没有任何事件发生,我们就会在函数内部返回。这个return语句意味着我们永远无法执行函数的结尾,也不会有对动画帧请求的另一个请求。循环停止。我们需要用户输入处理程序来触发另一个循环。
function animate(timestamp) { ... switch (phase) { case "waiting": return; // 停止循环 ... } ...}
拉伸阶段
在拉伸阶段,我们根据经过的时间增加最后一个杆的长度,并等待用户释放鼠标。最后一个杆始终位于英雄的前方。每次视图转换之后,都会在当前平台上添加一个新的杆。
function animate(timestamp) { ... switch (phase) { ... case "stretching": { sticks[sticks.length - 1].length += timePassed / stretchingSpeed; break; } ... } ...}
旋转阶段
在旋转阶段,我们改变最后一根棍子的旋转角度。我们只在棍子旋转到 90 度时进行,因为这表示棍子已经达到了平躺的位置。然后我们把阶段设置为行走,这样下一个 requestAnimationFrame
将调整主角而不是棍子。
一旦棍子旋转到 90 度,如果棍子落到了下一个平台上,我们还会增加分数。我们增加 score
状态,并更新 scoreElement
的 innerText
属性(参见 JavaScript 文件章节的概述)。然后我们生成一个新的平台,以确保我们永远不会用完平台。
如果棍子没有落在下一个平台上,我们不会增加分数,也不会生成新的平台。此外,我们也不会立即触发下落阶段,因为主角仍然试图沿着棍子行走。
function animate(timestamp) { ... switch (phase) { ... case "turning": { sticks[sticks.length - 1].rotation += timePassed / turningSpeed; if (sticks[sticks.length - 1].rotation >= 90) { sticks[sticks.length - 1].rotation = 90; const nextPlatform = thePlatformTheStickHits(); if (nextPlatform) { score++; scoreElement.innerText = score; generatePlatform(); } phase = "walking"; } break; } ... } ...}
这个阶段使用一个实用函数来判断棍子是否会落在平台上。它计算最后一根棍子的右端位置,并检查该位置是否在平台的左右边缘之间。如果是,则返回该平台;否则返回 undefined。
function thePlatformTheStickHits() { const lastStick = sticks[sticks.length - 1]; const stickFarX = lastStick.x + lastStick.length; const platformTheStickHits = platforms.find( (platform) => platform.x < stickFarX && stickFarX < platform.x + platform.w ); return platformTheStickHits;}
行走阶段
在行走阶段,我们使主角向前移动。这个阶段的结束取决于棍子是否达到下一个平台。为了确定这一点,我们使用刚刚定义的相同实用函数。
如果棍子的末端落在一个平台上,那么我们将主角的位置限制在该平台的边缘。一旦达到,我们就进入过渡阶段。但是,如果棍子的末端没有落在平台上,我们将主角的前进移动限制在棍子的末端,然后开始落下阶段。
function animate(timestamp) { ... switch (phase) { ... case "walking": { heroX += timePassed / walkingSpeed; const nextPlatform = thePlatformTheStickHits(); if (nextPlatform) { // 如果主角将到达另一个平台,请将其位置限制在其边缘 const maxHeroX = nextPlatform.x + nextPlatform.w - 30; if (heroX > maxHeroX) { heroX = maxHeroX; phase = "transitioning"; } } else { // 如果主角不会到达另一个平台,请将其位置限制在棍子的末端 const maxHeroX = sticks[sticks.length - 1].x + sticks[sticks.length - 1].length; if (heroX > maxHeroX) { heroX = maxHeroX; phase = "falling"; } } break; } ... } ...}
过渡阶段
在过渡阶段,我们移动整个场景。我们希望英雄站在屏幕上与初始位置相同的位置,但是现在他站在不同的平台上。这意味着我们必须计算我们需要将整个场景向后移动多少才能达到相同的位置。然后将阶段设置为等待,我们等待另一个鼠标事件。
function animate(timestamp) { ... switch (phase) { ... case "transitioning": { sceneOffset += timePassed / transitioningSpeed; const nextPlatform = thePlatformTheStickHits(); if (nextPlatform.x + nextPlatform.w - sceneOffset < 100) { sticks.push({ x: nextPlatform.x + nextPlatform.w, length: 0, rotation: 0, }); phase = "waiting"; } break; } ... } ...}
我们知道当平台的右边 – 被偏移值移动 – 到达第一个平台的原始右边位置时,我们已经到达了正确的位置。如果我们回顾一下初始化平台,我们会发现第一个平台的X位置始终为50,它的宽度也始终为50。这意味着它的右边将在100处。
在这个阶段结束时,我们还向sticks数组添加了一个新的stick,其初始值是:
掉落阶段
在失败的情景中,有两件事情在改变:英雄的位置和最后一个stick的旋转。然后一旦英雄从屏幕中掉出,我们通过从函数中返回来停止游戏循环。
function animate(timestamp) { ... switch (phase) { ... case "falling": { heroY += timePassed / fallingSpeed; if (sticks[sticks.length - 1].rotation < 180) { sticks[sticks.length - 1].rotation += timePassed / turningSpeed; } const maxHeroY = platformHeight + 100; if (heroY > maxHeroY) { restartButton.style.display = "block"; return; } break; } ... } ...}
所以这就是主循环 – 游戏如何从一个阶段过渡到另一个阶段,改变了一系列变量。在每个循环的结尾,函数调用draw
函数来更新场景并请求另一帧。如果你做得没错,你现在应该有一个正常工作的游戏!
摘要
在本教程中,我们涵盖了很多内容。我们学习了如何使用JavaScript在canvas
元素上绘制基本形状,并且实现了一个完整的游戏。
尽管本文很长,但这里还有一些我们没有涵盖到的内容。你可以在CodePen上查看本游戏的源代码以获取其他功能,包括:
- 如何使游戏适应整个浏览器窗口并相应地翻译屏幕。
- 如何为场景绘制背景以及如何绘制更详细的英雄版本。
- 我们在每个平台中间添加了一个双倍得分区域。如果stick的末端掉落到这个非常小的区域内,英雄将得到两分。
希望你喜欢本教程。敬请关注CodesCode和我的YouTube频道以获取更多内容。
Leave a Reply