俄罗斯方块计时器 - 第一部分
#javascript #d3 #tetris #timer

Tetris demo

介绍

在我的第一篇文章中,我在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数组有三个规则:

  1. 数组应对齐到左下方。我稍后再解释。
  2. 阵列有两种单元格:#.
  3. 细胞#必须与另一个#(不是浮动细胞)连接。
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.

如果我们假设从左侧(索引零)填充到右(最后一个索引),我们可以使它变得更容易,所以我们只需要在哪里放置拼图并以宽度的宽度移动该指针。难题。下面的动画应解释以下想法:

Tetris filling 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;
    }

如您所见,我们需要添加两种方法:pickRandomPuzzleputPuzzle。让我们从第一个方法开始:

    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ã阵列之间的碰撞。动画应有所帮助:

Matching puzzle

    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.

对于最后一行,我们从左向右插入拼图,因为该行是空的。但是现在,下一行还没有完全空。我们需要找到“差距”才能填补。也许动画应该有所帮助:

Creating gaps to fill

    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;
    }

让我们与findGapsfillGap
一起

    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')
        );
    }

Colorized puzzles

看起来很棒!

旋转

因为我们有一个不旋转的难题列表,所以我们应该编写为我们旋转每个拼图的代码。我们可以用作另一个难题的拼图的每个旋转,并且不需要修改以前的代码。

因为我们的结构是一个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');

这应该产生:

....
....
##..
##.. 

....
....
..##
..## 

..##
..##
....
.... 

##..
##..
....
....

如您所见 - 旋转有效,但不符合第一个条件:

  1. 该数组应对齐到左下

可悲的是,任何旋转都不符合这种情况(懒惰的旋转!),因此我们需要将单元格向左下:

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'));

,我们对每种旋转变体都有难题。我们可以显示结果:

Rotated puzzles

您注意到,下图显示了带有其他旋转的难题的板子。

结果和结论

今天,我们编写了一件不错的代码,该代码填充了游戏的董事会成员。不完美且不完工 - 在文章的实施中,我们仍然存在非填充单元格的问题,并且缺乏动画的网页。

本文中的代码在github repository上,完成的代码为here,但我认为有点困难,但是如果您想要…: - )

在下一部分中,我将写有关如何将4â阵列更改为svg polygon以及如何使用d3.js框架和香草js用动画制作计时器的方法。

感谢您阅读我的第一篇文章!