在这篇文章中,我描述了我如何开发了一个JavaScript "幸运之轮 "游戏,以使在全球大流行期间通过Zoom进行的在线会议更有趣。
目前的大流行病已经迫使许多社会活动走向虚拟。例如,我们当地的世界语小组,现在每月的语言学习聚会都在网上进行(而不是亲自参加)。作为小组的组织者,我不得不重新考虑我们的许多活动,因为冠状病毒的存在。以前,我可以在我们的活动组合中加入看电影,甚至在公园里散步,以避免疲劳(不断的语法演练并不鼓励重复参加)。
我们的新 "幸运轮 "游戏深受欢迎。当然,SitePoint是一个技术博客,所以我将介绍建立一个初级版本的游戏的概况,以便在我们的在线会议上进行屏幕共享。我将讨论我一路走来所做的一些权衡,以及强调一些改进的可能性和事后看来我应该做的不同的事情。
第一件事
如果你来自美国,你可能已经熟悉《财富之轮》,因为它是美国历史上运行时间最长的游戏节目。(即使你不在美国,你也可能熟悉这个节目的一些变体,因为它已被改编并在40多个国际市场播出)。该游戏本质上是刽子手游戏:参赛者试图通过猜测字母来解决一个隐藏的单词或短语。每个正确的字母的奖金数额由旋转一个大的轮盘式转盘决定,转盘上有美元数额和可怕的破产点。参赛者转动转盘,猜出一个字母,谜题中出现的所有字母都会被显示出来。猜对了,参赛者就有机会再转一次,而猜错了,游戏就会提前到下一个参赛者。当参赛者成功猜出单词或短语时,谜题就解开了。这个游戏的规则和各种元素经过多年的调整,你当然可以根据你的选手的需要调整你自己的版本。
对我来说,第一件事是决定我们如何实际(虚拟)地玩这个游戏。我只需要在一两个会议上玩这个游戏,而且我不愿意投入大量时间建立一个成熟的游戏平台,所以把这个应用程序建成一个网页,我可以在本地加载并与其他人进行屏幕共享,这很好。我将主持活动,并根据玩家的要求,用各种按键来推动游戏。我还决定用铅笔和纸来记分--这是我后来才后悔的事情。但最终,普通的JavaScript,一点点画布,以及少量的图像和声音效果文件,就是我构建游戏所需要的一切。
游戏循环和游戏状态
虽然我设想这是一个 "快速和肮脏 "的项目,而不是遵循所有已知最佳做法的出色编码杰作,但我的第一个想法仍然是开始建立一个游戏循环。一般来说,游戏代码是一个维护变量等的状态机,代表了游戏的当前状态,并有一些额外的代码来处理用户的输入,管理/更新状态,并以漂亮的图形和声音效果来渲染状态。被称为游戏循环的代码反复执行,触发输入检查、状态更新和渲染。如果你要正确地建立一个游戏,你很可能会遵循这种模式。但我很快意识到我不需要持续的状态监控/更新/渲染,所以我放弃了游戏循环,而采用了基本的事件处理。
在维护状态方面,代码需要知道当前的谜题,哪些字母已经被猜中,以及要显示哪个视图(谜题板或转盘)。这些对任何回调逻辑来说都是全局可用的。游戏中的任何活动都会在处理按键时被触发。
下面是核心代码开始的样子。
(function (appId) {
// canvas context
const canvas = document.getElementById(appId);
const ctx = canvas.getContext('2d');
// state vars
let puzzles = [];
let currentPuzzle = -1;
let guessedLetters = [];
let isSpinning = false;
// play game
window.addEventListener('keypress', (evt) => {
//... respond to inputs
});
})('app');
游戏板和谜题
财富之轮的游戏板基本上是一个网格,每个单元格处于三种状态之一。
- 空:空单元格在谜题中不使用(绿色)
- 空:该单元格代表谜题中的一个隐藏字母(白色)。
- 可见:该单元格显示了谜题中的一个字母。
编写游戏的一个方法是使用一个数组来代表游戏板,每个元素都是这些状态中的一个单元格,渲染这个数组可以用几种不同的方法来完成。这里有一个例子。
let puzzle = [...'########HELLO##WORLD########'];
const cols = 7;
const width = 30;
const height = 35;
puzzle.forEach((letter, index) => {
// calculate position
let x = width * (index % cols);
let y = height * Math.floor(index / cols);
// fill
ctx.fillStyle = (letter === '#') ? 'green' : 'white';
ctx.fillRect(x, y, width, height);
// stroke
ctx.strokeStyle = 'black';
ctx.strokeRect(x, y, width, height);
// reveal letter
if (guessedLetters.includes(letter)) {
ctx.fillStyle = 'black';
ctx.fillText(letter, x + (width / 2), y + (height / 2));
}
});
这种方法在谜题中迭代每个字母,计算起始坐标,根据索引和其他细节为当前单元格画一个矩形--比如一行的列数和每个单元格的宽度和高度。它检查字符并为单元格涂上相应的颜色,假设#,表示一个空的单元格,一个字母表示空白。然后在单元格上画出猜测的字母以显示它们。
另一种方法是事先为每个谜题准备一个静态的棋盘图像,并将其绘制在画布上。这种方法会给谜题的准备工作增加相当大的工作量,因为你需要创建额外的图像,可能还要确定每个字母在自定义棋盘上的位置,并将所有这些信息编码为适合渲染的数据结构。这样做的好处是可以得到更好看的图形,也许还有更好的字母位置。
这就是采用第二种方法的谜题的样子。
let puzzle = {
background: 'img/puzzle-01.png',
letters: [
{chr: 'H', x: 45, y: 60},
{chr: 'E', x: 75, y: 60},
{chr: 'L', x: 105, y: 60},
{chr: 'L', x: 135, y: 60},
{chr: 'O', x: 165, y: 60},
{chr: 'W', x: 45, y: 100},
{chr: 'O', x: 75, y: 100},
{chr: 'R', x: 105, y: 100},
{chr: 'L', x: 135, y: 100},
{chr: 'D', x: 165, y: 100}
]
};
为了提高效率,我建议加入另一个数组来追踪匹配的字母。如果只有guessedLetters ,你就需要反复扫描谜题中的字母以寻找多个匹配的字母。相反,你可以建立一个数组来追踪已解开的字母,当玩家进行猜测时,只需将匹配的定义复制到数组中,就像这样。
const solvedLetters = [];
puzzle.letters.forEach((letter) => {
if (letter.chr === evt.key) {
solvedLetters.push(letter);
}
});
渲染这个谜题时,看起来是这样的。
// draw background
const imgPuzzle = new Image();
imgPuzzle.onload = function () {
ctx.drawImage(this, 0, 0);
};
imgPuzzle.src = puzzle.background;
// reveal letters
solvedLetters.forEach((letter) => {
ctx.fillText(letter.chr, letter.x, letter.y);
});
为了记录在案,我在编写游戏时采用了第二种方法。但这里的重要启示是,同一个问题往往有多种解决方案。每种解决方案都有自己的优点和缺点,决定采用某种特定的解决方案将不可避免地影响你的程序设计。