介绍
在我的第一篇文章中,我在JavaScript和D3.js中介绍了带有Tetris动画的眼神计时器。
动机
几年前(在大流行时期)我是编码Dojo Silesia的共同组织者。编码Dojo的公式是将人们收集成对,对任务的解决方案成对(试图帮助理解主题的导师),在接下来的一个小时中,我们一起谈论解决方案。这是一个好时机 - 一些与会者学到了更好的技能,有人找到了更好或第一个CS工作。
在编码阶段,与会者需要在编码阶段结束时显示指向存储库的链接。但是投影仪上的链接和计时器有点无聊,所以我决定使用简单的动画制作着陆页。我决定制作一个由机器人而不是玩家玩的俄罗斯俄罗斯游戏。像沙漏;填充游戏的百分比意味着剩下的时间到编码结束。如果该区域填满了一半,则仅剩30分钟。如果该区域完成,则时间结束了。
不幸的是,由于大流行和参与者的缺乏,该事件被关闭了,俄罗斯方块计时器在Github的存储库中忘记了。因此,我决定还原此文章。
本文是第一部分 - 我试图写整篇文章,但这太大了。在这一部分中,我通过将难题插入板上显示了俄罗斯方块时间的 core 。
解决方案
TechStack
因为我想制作一个可以使用投影仪在每台计算机上运行的动画,所以我决定在Github页面上制作一个独立的网站。因为我想要一个网站,所以我需要选择一种JavaScript语言。俄罗斯方块的核心是用纯ecmascript6编写的,动画由SVG和D3.JS渲染。
代码并不完美 - 这是一个几个小时后的项目,目的是以吻原理编写代码的乐趣。某些代码行可能很难理解,例如字符串操作,2D数组索引或过滤/地图方法,但我尝试用注释来编写“清洁”。
我没有写过测试(多么可耻!),因为几年前我将其视为实验,而不是制作代码。
核
木板
首先,我们需要一个游戏板。因为首先,我想打印到终端简单结果,然后决定制作一个字符串,其中数组的元素是行,而行的元素是单元格。一个空单元是Dot .
,而非空单元是其他所有内容。让我尝试:
class Board {
constructor(width, height) {
this.width = width || 10;
this.height = height || 10;
this.board = Array(this.height) // Create the array of rows
.fill('.') // Fill anything to avoid an undefined value
.map(row => Array(this.width).fill('.')); // Each row is an array of characters.
}
toConsole() {
console.log( // Convert the array to string
this.board.map(row => row.join('')).join('\n')
);
}
}
const b = new Board(10, 10);
b.toConsole();
我们可以运行此代码,结果应为:
$ node new_board.js
..........
..........
..........
..........
..........
..........
..........
..........
..........
..........
也就是说,一个空板!现在,我们可以放一个难题。
谜
下一步是定义难题的结构。我考虑了两种方法:
- 每个难题是四个2D点的数组。
- 每个难题是一个4 4个单元格。
第一种方法更紧凑,因为我只需要4分来定义每个拼图,但是我决定使用4â4数组,因为更易于实现,并且可以使用代码中的ASCII ART之类的字符制作难题。<<<<<<<<<<<<<< /p>
在撰写文章期间,我一直在考虑第一种方法会更好(复杂性从O(4英)降低到O(4),一个用于循环的循环,而不是在两个维度上进行循环),但是4â4更容易想象和实施该算法。由于亲吻原则,并且由于我必须重写完成的代码,所以我选择了待更糟糕的方式。
4â4数组有三个规则:
- 数组应对齐到左下方。我稍后再解释。
- 阵列有两种单元格:
#
和.
。 - 细胞
#
必须与另一个#
(不是浮动细胞)连接。
class Puzzle {
constructor(array) {
this.array = array;
}
}
const PUZZLES = [
[
'....',
'....',
'##..',
'##..',
],
[
'....',
'....',
'##..',
'.##.', // The little caveat, I'll explain this later.
],
[
'#...',
'#...',
'#...',
'#...',
],
// Another puzzles
].map(a => new Puzzle(a));
填充最后(底部)行
根据真实的游戏,第一个填充的排是最后一排。最后一行非常简单:
while is empty place in the row:
pick random puzzle
put puzzle somewhere in the row.
如果我们假设从左侧(索引零)填充到右(最后一个索引),我们可以使它变得更容易,所以我们只需要在哪里放置拼图并以宽度的宽度移动该指针。难题。下面的动画应解释以下想法:
但是,在我们需要设置每个拼图两个值之前:
- 从左侧开始最后一行。 (大多数难题从0索引开始,但是Zâuzzle以1个索引开始)
- 停止最后一行。
最后一行的宽度是停止索引和开始索引之间的差异。
因此,我们需要在拼图类中添加三个属性:
class Puzzle {
constructor(array) {
this.array = array;
const lastRow = array[3];
this.lastRowStart = lastRow.indexOf("#");
this.lastRowStop = lastRow.lastIndexOf("#");
this.lastRowWidth = this.lastRowStop - this.lastRowStart + 1;
}
}
让我们尝试填充最后一行的主要逻辑:
class Board {
constructor(width, height) {
// previous code
this.rowIndex = this.height - 1;
}
fillLastRow() {
let pointer = 0;
while(pointer < this.width) {
const coords = [pointer, this.rowIndex];
const puzzle = this.pickRandomPuzzle(coords);
if (puzzle === null) {
// Let's try with another place.
pointer += 1;
continue;
}
this.putPuzzle(puzzle, coords);
pointer += puzzle.lastRowWidth;
}
this.rowIndex -= 1;
}
如您所见,我们需要添加两种方法:pickRandomPuzzle
和putPuzzle
。让我们从第一个方法开始:
pickRandomPuzzle(coords) {
const validPuzzles = PUZZLES
.filter(puzzle => this.isPuzzleMatching(puzzle, coords));
if (validPuzzles.length == 0) {
// Any puzzle is not valid.
return null;
}
const index = Math.floor(Math.random() * validPuzzles.length);
return validPuzzles[index];
}
下一个方法是isPuzzleMatching
,它检查是否可以将难题放入板上。匹配的主要目标是检查与板的一部分难题之间的4ã阵列之间的碰撞。动画应有所帮助:
isPuzzleMatching(puzzle, [startX, startY]) {
// The main idea is to check the board cell with a puzzle cell.
// So we have to check 4x4 area.
for(let y = 0; y < 4; y++) {
for(let x = 0; x < 4; x++) {
const puzzleCell = puzzle.array[y][x];
// Assume start of the puzzle coordination
// is the first left bottom non-empty cell.
const boardX = startX + x - puzzle.lastRowStart;
const boardY = startY + y - 3;
// boardX/Y could be a negative / above size of board.
// so we need to check if a row and a cell exists.
// If coords are above size of board then
// we can treat cell as "solid".
const boardRow = this.board[boardY] || [];
const boardCell = boardRow[boardX] || "#";
if (boardCell != "." && puzzleCell != ".") {
// Collision between the board and the puzzle,
// so the puzzle can't fit in the board.
return false;
}
}
}
// All cells are matching with board.
return true;
}
好吧,现在让我们尝试使用putPuzzle
方法:
putPuzzle(puzzle, [startX, startY]) {
// Assume a puzzle can be added to the board.
// Otherwise it could "damage" the board.
for(let y = 0; y < 4; y++) {
for(let x = 0; x < 4; x++) {
const puzzleCell = puzzle.array[y][x];
// Assume start of the puzzle coordination
// is the first left bottom non-empty cell.
const boardX = startX + x - puzzle.lastRowStart;
const boardY = startY + y - 3;
if ( // check if the cell on the board exists.
boardX >= 0
&& boardX < this.width
&& boardY >= 0
&& boardY < this.height) {
this.board[boardY][boardX] = puzzleCell;
}
}
}
}
最后:
const b = new Board(10, 10);
b.fillLastRow();
b.toConsole();
$ node new_board.js
..........
..........
..........
..........
..........
..........
..........
...#.....#
##.#.#.#.#
##########
这是第一次尝试,但看起来不好。让我们尝试索引:
class Board {
constructor(width, height) {
// The previous code.
this.puzzleIndex = 0;
}
// previous code
putPuzzle(puzzle, [startX, startY]) {
// Assume a puzzle can be added to the board.
// Otherwise it could "damage" the board.
for(let y = 0; y < 4; y++) {
for(let x = 0; x < 4; x++) {
const puzzleCell = puzzle.array[y][x];
if (puzzleCell == ".") {
continue;
}
// Assume start of the puzzle coordination
// is the first left bottom non-empty cell.
const boardX = startX + x - puzzle.lastRowStart;
const boardY = startY + y - 3;
if ( // check if the cell on the board exists.
boardX >= 0
&& boardX < this.width
&& boardY >= 0
&& boardY < this.height) {
const ascii = 48 + this.puzzleIndex % 10;
const char = String.fromCharCode(ascii);
this.board[boardY][boardX] = char;
}
}
}
this.puzzleIndex += 1;
}
$ node new_board.js
..........
..........
..........
..........
..........
..........
0.........
0...2.....
0.112.3344
011223344.
更好 - 我们现在可以看到单个难题!我们可以尝试颜色,但是我们有更必要的工作。如您所见,我们对最后一行有问题 - 未填补。因为反向Zâuzzle在板上匹配,但留下一个单元格。让我们跳过此操作并尝试以另一种方式修复。
填充下一行
让填充行回到想法:
while is empty place in the row:
pick random puzzle
put puzzle somewhere in the row.
对于最后一行,我们从左向右插入拼图,因为该行是空的。但是现在,下一行还没有完全空。我们需要找到“差距”才能填补。也许动画应该有所帮助:
fillLastRow() {
const gaps = this.findGaps();
gaps.forEach(gap => this.fillGap(gap));
this.rowIndex -= 1;
// Return if is possible to fill another rows.
return this.rowIndex > 0;
}
让我们与findGaps
和fillGap
一起
findGaps() {
const row = this.board[this.rowIndex];
const gaps = [];
let pointer = 0;
while(pointer < this.width) {
// Find the next empty cell
const start = row.findIndex((c, i) => i >= pointer && c == ".");
if (start == -1) { // not found
break;
}
// Find the next non-empty cell
const end = row.findIndex((c, i) => i >= start && c != ".");
if (end == -1) { // not found
gaps.push([start, this.width - 1]);
break;
}
gaps.push([start, end - 1]);
pointer = end;
}
return gaps;
}
fillGap([start, end]) {
// Basically it's the old fillLastRow method.
let pointer = start;
while(pointer <= end) {
const coords = [pointer, this.rowIndex];
const puzzle = this.pickRandomPuzzle(coords);
if (puzzle === null) {
// Let's try with another place.
pointer += 1;
continue;
}
this.putPuzzle(puzzle, coords);
pointer += puzzle.lastRowWidth;
}
}
和运行:
const b = new Board(10, 10);
while(b.fillLastRow());
b.toConsole();
$ node new_board.js
..........
..........
3.....4..5
3...1.4..5
3.9.1042.5
3596104285
0596102284
0596207784
0516277384
0111223334
看起来非常好!让我们尝试使用unix颜色(非常骇人听闻的代码,不担心;对于算法的核心不是必需的):
toColorConsole() {
console.log(
this.board.map(
row => row
.join('')
.replace(/\d/g, w => {
const d = w.charCodeAt() - 48;
const fg = parseInt(d) + (d < 7 ? 31 : 91 - 7);
const bg = parseInt(d) + (d < 7 ? 41 : 101 - 7);
return `\x1b[${fg}m\x1b[${bg}m${w + w}\x1b[0m`;
})
.replace(/\./g, '..')
).join('\n')
);
}
看起来很棒!
旋转
因为我们有一个不旋转的难题列表,所以我们应该编写为我们旋转每个拼图的代码。我们可以用作另一个难题的拼图的每个旋转,并且不需要修改以前的代码。
因为我们的结构是一个4不阵列,所以旋转不是一个问题,因为我们有an algorithm:
function rotate90(array) {
// Let's create an array of arrays
const newArray = Array(4).fill('.').map(() => ['.', '.', '.', '.']);
for(let y=0; y < 4; y++) {
for(let x=0; x < 4; x++) {
const cell = array[y][x];
const row = newArray[4 - x - 1];
row[y] = cell;
}
}
// Return to array of strings.
return newArray.map(c => c.join(''));
}
写一个示例:
const p0 = [
'....',
'....',
'##..',
'##..',
];
const p1 = rotate90(p0);
const p2 = rotate90(p1);
const p3 = rotate90(p2);
console.log(p0.join('\n'), '\n');
console.log(p1.join('\n'), '\n');
console.log(p2.join('\n'), '\n');
console.log(p3.join('\n'), '\n');
这应该产生:
....
....
##..
##..
....
....
..##
..##
..##
..##
....
....
##..
##..
....
....
如您所见 - 旋转有效,但不符合第一个条件:
- 该数组应对齐到左下
可悲的是,任何旋转都不符合这种情况(懒惰的旋转!),因此我们需要将单元格向左下:
function shiftToBottomLeft(array) {
array = shiftToBottom(array);
array = shiftToLeft(array);
return array;
}
function shiftToBottom(array) {
const newArray = [...array];
// If the last row is empty
// then pop the row
// and put on the top.
while(newArray[3] == '....') {
const row = newArray.pop();
newArray.unshift(row);
}
return newArray;
}
function shiftToLeft(array) {
let newArray = [...array];
// If the first column is empty
// then for each row drop the first cell
// and put an empty cell on the end of row.
while(newArray.every(col => col[0] == '.')) {
newArray = newArray.map(col => col.substr(1) + '.');
}
return newArray;
}
const p0 = [
'....',
'....',
'##..',
'##..',
];
const p1 = rotate90(p0);
const p2 = rotate90(p1);
const p3 = rotate90(p2);
console.log(shiftToBottomLeft(p0).join('\n'), '\n');
console.log(shiftToBottomLeft(p1).join('\n'), '\n');
console.log(shiftToBottomLeft(p2).join('\n'), '\n');
console.log(shiftToBottomLeft(p3).join('\n'), '\n');
的结果是:
....
....
##..
##..
....
....
##..
##..
....
....
##..
##..
....
....
##..
##..
您注意到 - 我们有四次旋转,结果是同样的难题。因此,如果旋转的难题仍然是相同的难题:
function isArrayEquals(a, b) {
// Checks each cell.
for(let y=0; y < 4; y++) {
for(let x=0; x < 4; x++) {
if (a[y][x] != b[y][x]) {
return false;
}
}
}
return true;
}
最后,我们可以编写代码以旋转每个难题:
function makePuzzleWithRotating(puzzle) {
const rotations = [puzzle];
let rot = puzzle;
for(let i=0; i < 4; i++) {
const newRot = shiftToBottomLeft(rotate90(rot));
const isAnyEqual = rotations.some(r => isArrayEquals(r, newRot));
if (!isAnyEqual) {
rotations.push(newRot);
}
rot = newRot;
}
return rotations.map(p => new Puzzle(p));
}
const PUZZLES = [
// some puzzles
].map(makePuzzleWithRotating).flat();
我们可以运行代码:
PUZZLES.forEach(p => console.log(p.array.join('\n'), '\n'));
,我们对每种旋转变体都有难题。我们可以显示结果:
您注意到,下图显示了带有其他旋转的难题的板子。
结果和结论
今天,我们编写了一件不错的代码,该代码填充了游戏的董事会成员。不完美且不完工 - 在文章的实施中,我们仍然存在非填充单元格的问题,并且缺乏动画的网页。
本文中的代码在github repository上,完成的代码为here,但我认为有点困难,但是如果您想要…: - )
在下一部分中,我将写有关如何将4â阵列更改为svg polygon以及如何使用d3.js框架和香草js用动画制作计时器的方法。
感谢您阅读我的第一篇文章!