Canvas上的下落方块






4.97/5 (64投票s)
V.7.5.1:衍生作品:纯 HTML + JavaScript + 画板实现的可定制坠落方块,使用严格模式,包含帮助和所有经典的坠落方块操作
目录
1. 引言
这是我完成的第一个 JavaScript 作品,不算上那些完全琐碎的东西。我不能说我以前从未尝试过纯娱乐领域的事情,但同样,没有什么值得一提的。
坠落方块是一款传奇游戏,以其极简和吸引人的独特组合而闻名。
不幸的是,我很久以来没有见过一款真正可玩的游戏实现了。不,我不是玩家,也没有见过多少。首先,我不会冒着没有开源的风险,但我看到的并不算真正可玩,因为它缺少最重要的功能和应有的外观和感觉,与 DOS 时代的旧实现相比差远了。
与此同时,使用新的 HTML(HTML5)画板元素的 HTML 加 JavaScript 是开发简单游戏的最佳平台。它只需要一个浏览器,应该能在所有平台上运行,并且始终附带源代码。所以,当我偶然发现这样的实现时,我非常高兴。显而易见,我发现的不完整作品是由一位相当有能力的作者编写的。但同时,它还远非可玩,而且代码质量从总体设计上来说并没有让我满意,尽管它很清晰且总体上正确。这主要是由于初始设计中缺乏灵活性以及缺少重要的功能。但如果你有整洁的源代码,这又怎么会是个问题呢?所以我决定从头重写。
2. 衍生作品
这是杰克·戈登(Jake Gordon)的原作,引起了我的注意
- http://codeincomplete.com,
- http://codeincomplete.com/posts/2011/10/10/javascript_tetris,
- https://github.com/jakesgordon/javascript-tetris
我几乎从头重写了 100% 的代码,但我使用了杰克开发的所有底层算法,并遵循了他的大部分算法思想,以及应用程序的总体设计,分解成主要模块:基本辅助方法、带有事件队列的游戏、带有失效机制的渲染和主应用程序。杰克的原始作品并不完整,但一些初始设计功能已经将开发引向了错误的方向,并且至少缺少一个功能使得游戏无法真正玩。但我喜欢这个解决方案的基础,并非常想修复所有这些,创造一个真正可用且维护良好的产品。
我没有遵循原始代码设计,将其进一步分解为“常量”、“变量”、“逻辑”等部分。相反,我将设置对象的设计放在一个单独的文件“settings.js”中,引入了一个带有原型方法的独立构造函数来表示俄罗斯方块元素和其他结构元素,这些元素以一组独立的 JavaScript 对象形式正式表达,例如简单的 FSM 和布局。
因此,首先,这可以轻松地定制在原始作品中完全僵化的内容,最重要的是,游戏的大小(以方块为单位)可以在合理的范围内进行修改。即使在 100 x 100 方块大小的棋盘上玩游戏也变得可能(但话说回来,非常烦人 :-))。
3. 新功能
我提到了缺少使游戏实现无法玩的功能。不幸的是,大多数我看到的实现都缺少此功能。这是什么?应该有一个按键(最初是空格键),可以将当前的俄罗斯方块立即下落到底部,在那里仍然可以移动,如果空间足够。所以,我添加了这个重要的功能。
我完全改变了页面的布局。杰克的原始设计基于一套固定的预定义布局,适用于不同的页面尺寸。也许他认为这样会更简单,但事实并非如此。它不仅增加了多余的 CSS 代码,而且看起来很丑。现在,无论页面尺寸如何,游戏在网页上看起来都是对称的。用户甚至可以在游戏过程中随时调整页面大小。布局根据 window.innerHeight
和方块游戏板的大小进行重新计算。重新计算是为了保持方块的大小为整数(非小数),因此棋盘相对于页面内部高度的相对大小会发生变化,以保持所有宽高比不变,代价是可变的游戏区域边距。换句话说,它的设计风格是可良好调整大小的桌面应用程序。
更重要的是,游戏可以定制。首先,游戏板的大小(以方块为单位)由于上述布局和宽高比问题而无法更改。现在,正如我之前提到的,可以在一个单独的文件中更改它,以及方块的颜色甚至形状。我将在文章的下一节中进行描述。我实际上改变了颜色和原始方向,以使游戏更具可玩性,并更接近其原始设计。
我还添加了随时在同一页面上显示帮助的功能。
内部,我创建了不同的、结构严谨的代码设计,使用了 JavaScript 的严格模式和异常处理,并提高了性能。我将在第 5 节中简要描述这个设计,但首先会描述哪些内容可以定制。
4. 定制
游戏的可定制部分放在一个单独的文件“settings.js”中。
- 游戏大小(以方块为单位)可以更改,这归功于上述布局的更改。此声明可以更改
const gameSizeInBlocks = { x:10, y:20 }
- 按键分配可以在
key
对象中更改。此对象的属性名称是按功能命名的,而不是按键名。默认情况下,[Enter] 用于开始/暂停/继续游戏,[Esc] 停止当前游戏,箭头键移动当前的俄罗斯方块元素(“上”键旋转它),空格键将其下落,[F1] 显示和隐藏帮助。
- 游戏时序可以在
delays
对象中更改。此对象定义了在将俄罗斯方块元素移动一行之前的时间延迟:初始延迟、最小延迟以及为加速游戏而应用的延迟递减(随着用户进展)。根据游戏规则,随着总行数的增加,延迟也会以一个常数值递增。
- 分数规则可以在
scoreRules
对象中更改。规则定义了在每个俄罗斯方块下落以及移除某些行时添加的分数。规则可以是任何用户定义的函数,根据当前移除的行数、分数和一次要移除的行数来计算添加的分数。默认情况下,每次下落的方块会添加固定分数,而一次移除多行时添加的分数会随着移除行数的增长而呈幂函数增长。这是根据原始游戏设计的,其中玩家会受到激励去收集不完整的行数,然后一次完成最多 4 行。这会让玩家获得奖励。
- 最后,可以更改俄罗斯方块的颜色和形状。我将在第 6 节中解释。
5. 总体代码设计
游戏的核心单元是 Tetromino
构造函数及其原型对象上的两个方法,将在第 7 节中描述。
代码从“settings.js”文件开始,该文件首先包含在 HTML 中,而主代码在“application.js”中。
layout
对象获取主要的 DOM 元素并实现原始布局以及窗口大小变化时的布局行为。下一个对象,game
,定义了抽象于图形渲染的游戏逻辑,这委托给 rendering
对象,该对象使用HTML5 画板功能,
一组几个简单的基本实用函数放在所有这些下面,接着是游戏的主匿名函数,它以 IIFE 的形式实现,这有助于将所有局部函数保持在主函数外部无法访问。此模式在整个代码中使用。(请参阅这篇关于 IIFE JavaScript设计模式的文章,“立即调用的函数表达式”。)
此设计模式还优雅地解决了 JavaScript严格模式的要求。它有助于使用内部函数,同时将主代码包装在 try-catch 块中,这尤其对于开发很重要。
主函数初始化游戏,安装事件处理程序并启动第一个帧;其他帧通过 window.requestAnimationFrame
请求。异常捕获的使用仅限于最顶层:每个事件处理程序和主函数都遵循结构化异常处理的理念。
下一步,我将描述代码中最有趣的部分:算法代码及其实现。
6. 底层俄罗斯方块
这是位运算定义俄罗斯方块形状的片段
function TetrominoShape(size, blocks, color) {
this.size = size; this.blocks = blocks; this.color = color;
}
const tetrominoSet = [
new TetrominoShape(4, [0x0F00, 0x2222, 0x00F0, 0x4444], tetrominoColor.I),
//...
];
这是每个形状对象二进制表示的描述
// blocks: each element represents a rotation of the piece (0, 90, 180, 270)
// each element is a 16 bit integer where the 16 bits represent
// a 4x4 set of blocks, e.g. "J"-shaped tetrominoSet[1].blocks[1] = 0x44C0
//
// 0100 = 0x4 << 3 = 0x4000
// 0100 = 0x4 << 2 = 0x0400
// 1100 = 0xC << 1 = 0x00C0
// 0000 = 0x0 << 0 = 0x0000
// ------
// 0x44C0
7. 俄罗斯方块构造函数和原型
我引入了 Tetromino
构造函数对象,原因如下:同时提高代码性能和可维护性。相关的 JavaScript 功能通常被称为“OOP”和“类”,但这些术语非常具有误导性,或者至少有争议;JavaScript 基于原型的对象机制与“面向类的 OOP”根本不同。
这是构造函数
function Tetromino(shape, x, y, orientation) {
this.shape = shape; //TetrominoShape
this.x = x;
this.y = y;
this.orientation = orientation;
} //Tetromino
并向其原型添加了两个方法
Tetromino.prototype = {
// fn(x, y), accepts coordinates of each block, returns true to break
first: function(x0, y0, orientation, fn, doBreak) {
let row = 0, col = 0, result = false,
blocks = this.shape.blocks[orientation];
for(let bit = 0x8000; bit > 0; bit = bit >> 1) {
if (blocks & bit) {
result = fn(x0 + col, y0 + row);
if (doBreak && result)
return result;
} //if
if (++col === 4) {
col = 0;
++row;
} //if
} //loop
return result;
}, //Tetromino.prototype.first
all: function(fn) { // fn(x, y), accepts coordinates of each block
this.first(this.x, this.y, this.orientation, fn, false); // no break
} //Tetromino.prototype.all
} //Tetromino.prototype
这两个方法是底层算法的核心:它们实现了众所周知的“first of”和“all”模式。它们遍历俄罗斯方块形状中的所有方块,第一个方法会在某个由函数参数提供的条件为真时中断搜索。
这带来了主要的性能改进之一,因为原始代码在所有情况下都会遍历给定形状的所有方块。(另请注意,first
和 all
只创建一次;这就是为什么这个原型赋值是在构造函数之外完成的。)
这是函数 first
在游戏逻辑中的使用方式
willHitObstacle: function(tetromino, x0, y0, orientation) {
// tentative move is blocked with some obstacle
return tetromino.first(x0, y0, orientation, function(x, y) {
if ((x < 0)
|| (x >= gameSizeInBlocks.x)
|| (y < 0)
|| (y >= gameSizeInBlocks.y)
|| game.getBlock(x,y))
return true;
}, true);
}, //willHitObstacle
一旦传递给 tetromino.first
的匿名函数返回 true,函数 first
也会立即返回 true,从而中断遍历俄罗斯方块方块的循环。这表明遇到了第一个障碍物,可能是墙壁或其他方块。检测到第一个障碍物后,无需进一步考虑障碍物,因此 willHitObstacle
函数此时返回 true。
函数 Tetromino.all
的使用更简单:遍历形状的所有方块。这用于,例如,在 HTML 画板上绘制俄罗斯方块元素。
8. 内部函数和事件处理:传递“this”
让我们来看一个更有趣的细节:现在添加了事件处理程序。考虑一个就足够了。可以这样做
function someFunction(event) { /* ... */ }
document.onkeydown = someFunction;
它会起作用吗?一个小问题是处理函数实现为游戏对象的成员。那么下面的代码也会起作用吗?
document.onkeydown = game.keydown;
不完全是。问题在于 keydown
函数不仅使用事件参数,还使用隐式参数 this
,该参数用于访问 game
对象中的其他成员。如果事件处理程序按上述方式添加,那么 this
参数仍然会像往常一样传递,但它将,不出所料,指向… document
对象。我不是为自己制造了一些人为的问题吗?一点也不是。这个问题可以很容易地这样解决
document.onkeydown = function(event) { game.keydown(event); };
请注意,event
参数应该显式传递。
内部函数也发生类似的情况。看 rendering
对象的一个简短片段
const rendering = {
// ...
promptText: element("prompt"),
rowsText: element("rows"),
pausedText: element("paused"),
invalid: { board: true, upcoming: true, score: true, rows: true, state: true },
// ...
// ...
draw: function() {
const drawRows = function() {
if (!this.invalid.rows) return;
setText(this.rowsText, game.rows);
this.invalid.rows = false;
}; //drawRows
const drawState = function() {
if (!this.invalid.state) return;
setText(statusVerb, game.states.current === game.states.paused ? "continue" : "start");
setVisibility(this.pausedText, game.states.current === game.states.paused);
setVisibility(this.promptText, game.states.current != game.states.playing);
this.invalid.state = false;
}; //drawState
// ...
drawRows.call(this);
drawState.call(this);
// compare:
// drawState(); //won't work
// drawState(this); //won't work
// ...
} //draw
} //rendering
在我设计的初期,几个绘图方法,如 drawRows
或 drawState
,被定义为渲染属性,直到我发现它们除了在 draw
中使用外,别处都不会被使用,所以最好通过将它们设为内部函数来隐藏它们。从上面显示的这段代码中,可以看到它们使用隐式 this
参数来访问 rendering
对象中的成员。为什么直接调用(代码示例中已注释掉)不起作用?在 JavaScript 中,传递给内部函数的 this
参数将是外部函数对象,而不是 rendering
对象。解决方法是使用函数的 call
方法,该方法简单地显式传递 this
:https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/call
当然,也可以添加一个显式参数并传递 rendering
,但使用已经始终传递的隐式 this
参数是一种自然且经济的解决方案。毕竟,我想解释一种处理内部函数的非常通用的技术。
9. 未来发展?
我没有计划为游戏添加鼠标/触摸支持。我实际上实现了一个带有鼠标控制的实验版本,然后决定删除它:它基本上在不同变体中都能工作,但用鼠标玩非常笨拙,不方便。
为拥有此设备的客户端计算机添加加速计和陀螺仪支持会更实用。但这更像是原生 Windows、Linux 或 Android 的工作。至于使用任何 Web 技术,我认为很重要…等等。我坚信,只有当任何设备的使用在 W3C 标准化后,才应该纳入公共应用程序,即使是像网络摄像头或指纹读取器这样广泛使用的设备。在我看来,当适当的 W3 草案或仅仅是提案发展成标准并在主流浏览器中实现后,所有此类设备都可以使用。例如,请参见
我不想打破 JavaScript 的隔离并与文件系统交互来存储设置。出于非常好的原因,这被认为是不安全的。由于我设计的应用程序更多是为了“家庭使用”,而不是在线玩,因此从 UI 生成设置文件并在后台下载它是完全可能的,而且相当容易,用户可以手动替换它。
我认为唯一合法的方式是使用Web 本地存储来存储游戏设置。显然,这将是需要实现的另一个附加功能。另一个功能是游戏启动时用方块填充游戏选项,具有选定的平均密度和高达特定高度。这是原始游戏中一个流行的功能,我很想实现它,因为我认为这是玩它的最有趣的方式。由于一些原因,它比其他功能更不简单,所以我只是在考虑它。
[更新]
v. 7.0 中实现了交互式在线游戏设置编辑器,并使用Web 本地存储进行永久数据存储。
我邀请任何人发送任何建议或创作任何形式的衍生作品。
10. 版本
1.0: 2015 年 2 月 15 日:第一个功能齐全的版本,如文章所述。
1.1: 2015 年 2 月 19 日:功能相同版本,帮助框中添加了版本信息、游戏信息链接、许可证、贡献者和原始出版物。这是为了能够将产品发布到独立的网页上,与本文分开,同时显示这些法律敏感信息。
2.0: 2015 年 2 月 19 日:修复了已知的浏览器兼容性问题。
3.0: 2015 年 9 月 20 日:现代化 JavaScript 代码,将文本帮助显示/关闭按钮替换为 SVG 图像,并为不兼容的浏览器添加了注释。
4.0: 2019 年 1 月 20 日:修复了俄罗斯方块下落后的行为(使用空格键):现在其位置固定,无法再移动;移动键影响下一个俄罗斯方块元素。
4.1: 2019 年 1 月 23 日:实现了更高级的空格字符处理。
现在,只有当按键不是自动重复的空格键,或者当按下 Ctrl+Space 时,它才会下落当前俄罗斯方块。
KeyboardEvent.repeat
可能未在所有浏览器中实现,因此使用 game.repeatedKeyDropDown
属性模拟此属性。帮助已相应更新。
7.0: 2019 年 2 月 1 日:许多新功能。
- 添加了“下载源代码”和“设置”命令。
- 新的“设置”页面提供了一种交互式且便捷的方式来自定义游戏大小(以方块为单位)、时序(速度和速度增长)、俄罗斯方块颜色和按键分配,以及“杂乱”。自定义数据保存在浏览器的本地存储中,并可以随时删除。
- “杂乱”是经典俄罗斯方块最佳旧实现中特有的功能,它增加了游戏的趣味性。游戏区域堆积了随机的俄罗斯方块,直到达到某个高度(由用户在设置中以百分比指定,或在游戏开始前立即指定)。然后用户可以尝试清理杂乱。
- 许多便捷功能和更好的帮助。特别是,自定义按键分配会在帮助中反映出来。
7.1: 2019 年 2 月 5 日:修复了不允许 localStorage
(DOM Storage)的浏览器的问题—实现了备用方案:游戏可以运行,但无法将自定义数据存储在本地存储中。
在以下浏览器中进行了测试,发现了该问题:Microsoft Edge 42.17134.1.0、EdgeHTML 17.17134、2018。
11. 在线试玩
游戏可以在这里在线试玩。
12. 结论
出于一些非常好的原因,JavaScript 有时被认为是世界上最被误解的语言
http://javascript.crockford.com/javascript.html
https://yow.eventer.com/yow-2013-1080/the-world-s-most-misunderstood-programming-language-by-douglas-crockford-1377.
我从这次练习中学到的一个重要教训是:通过深入了解基本功能来学习正确实践,并远离那些太容易蒙蔽 minds 的幻觉,这一点非常重要。避免被炒作和那些不称职但看似很有说服力的人制造的干扰所迷惑,这一点也非常重要。什么样的干扰?这里描述了一些:http://davidwalsh.name/javascript-objects-distractions。
我认为所有这些“破除迷信”都非常有益,但这……是完全不同的故事。