logo
LIS PUBLICA
☰
  • Новое
  • Горячее
  • Сокровищница
  • Лучшее
  • Сообщества
  • Обсуждаемое

VariusSoft
VariusSoft Сообщество: GameDev Опубликовано 3 часа назад
  • [моё]
  • GameDev
  • Программирование

Как написать Tetris

Gamedev для самых маленьких

Вчера я остро ощутил, что очень давно не писал ничего простого, но функционального и интересного. А ещё я тут всё про игры да про игры, поэтому решил: пора!

Последнее, что я писал подобного – это змейку, которую можно было наблюдать во время обновлений сайта раньше (помните её?).

Да и ту на половину, а то и на две трети написал за меня чатГПТ. Поэтому я решил, что возьму нового подопытного и реализую всё сам. Мозг не должен забывать, как делать штуки...

В качестве основы решил взять HTML5 и ванильный JS, как технологию с самым дешёвым вариантом рендера, да и повторить, при желании, этот же алгоритм можно на любом другом движке.

Начнём с простого: нам понадобится текстовый редактор любой. Хорошо, если с подсветкой синтаксиса. Для самого простого можно взять Notepad++, для случаев чуть серьёзнее Атом или ВС код.

Создаём html страничку, она будет нашей точкой входа, рядом с ней создаём файлик стилей и основной наш скриптец. Забегая вперёд: стили нам не то, чтобы прям сильно нужны.

Дожидаемся, пока вся страница загрузится, потом инициируем игру

    document.addEventListener('DOMContentLoaded', () => {
    initGame();
});

Что в initGame?

    const initGame = () => {
    field = new GameField(10, 20);

    canvas.width = canvas.offsetWidth;
    canvas.height = canvas.offsetHeight;
    recordEl.innerText = bestScore;

    resetGame();
};

Очевидно, что canvas, recordEl, field - это некие переменные, который мы где-то объявляем.

Это делается в самом начале скрипта. Там же получаем и экранные кнопки.

    const canvas = document.getElementById('game');
const context = canvas.getContext('2d');
const scoreEl = document.getElementById('score');
const recordEl = document.getElementById('record');
const restartBtn = document.getElementById('btn-restart');
const gameShell = document.getElementById('gameShell');
const gameOverOverlay = document.getElementById('gameOverOverlay');
const gameOverlayTitle = gameOverOverlay?.querySelector('strong');

const btnUp = document.getElementById('btn-up');
const btnDown = document.getElementById('btn-down');
const btnLeft = document.getElementById('btn-left');
const btnRight = document.getElementById('btn-right');

Пока что к самому алгоритму игры мы не приступили, это всё предварительные ласки.

В первую очередь давайте посмотрим на класс GameField.

    class GameField {
    constructor(width, height) {
        this.width = width;
        this.height = height;
        this.blocks = [];
        for (let i = 0; i < this.height; i++) {
            let line = [];
            for (let j = 0; j < this.width; j++) {
                line.push(0);
            }
            this.blocks.push(line); //Заполняем массив ячеек поля нулями. поле по умолчанию пустое
        }
    }

    checkLines = async () => { // тут мы проверяем, а есть ли линии, которые надо сбросить
        for (let y = this.height - 1; y >= 0; y--) {
            if (this.currentLineIsFill(this.blocks[y])) {
                currentScore++; //плюсуем очки

                for (let x = 0; x < this.width; x++) {
                    this.blocks[y][x] = 0;
                    redraw();
                    await sleep(LINE_CLEAR_ANIMATION_DELAY); // это просто для красивой анимации исчезновения
                }
            }
        }
        currentScore = getComboPoints(currentScore); // этот метод отдельно, там считаем прирос за комбо
    }

    currentLineIsEmpty = (line) => {
        for (let x = 0; x < line.length; x++) {
            if (line[x] === 1) { // если хоть одна ячейка заполнена, то идёт нахер
                return false;
            }
        }

        return true;
    };

    currentLineIsFill = (line) => {
        for (let x = 0; x < line.length; x++) {
            if (line[x] === 0) { // если хоть одна ячейка пустая, строка идёт нахер
                return false;
            }
        }

        return true;
    };

    moveLines = () => { // сдвигаем после удаления
        let notEmptyLines = [];
        for (let y = 0; y < this.blocks.length; y++) {
            if (!this.currentLineIsEmpty(this.blocks[y])) {
                notEmptyLines.push([...this.blocks[y]]); // сначала заполняем массив непустымы строками
            }
        }

        let emptyLine = [];
        for (let x = 0; x < this.width; x++) {
            emptyLine.push(0); // потом досоздаём массив пустых строк
        }

        let newLines = []; // запиххиваем их в новый массив
        for (let x = 0; x < this.height - notEmptyLines.length; x++) {
            newLines.push([...emptyLine]);
        }
        for (let x = 0; x < notEmptyLines.length; x++) {
            newLines.push([...notEmptyLines[x]]);
        }

        this.blocks = newLines;
    }
}

Когда же мы будем всё это дёргать? И почему?

В методе, который описывает игровой цикл. Я постарался программировать так, чтоб даже при современном подходе можно было бы реализовать близкий по сути алгоритм на реальном камне, где, как известно многопоточность лишь выдуманная.

    const resetGame = () => {
    clearTimeout(tickTimeout);

    field = new GameField(10, 20);
    currentFigure = null;
    nextFigure = new Figure();
    nextFigure.fillRandom();

    score = 0;
    currentScore = 0;
    gameMode = GameMode.PLAYING;
    isSoftDropping = false;
    isLoopRunning = false;

    scoreEl.innerText = '0';
    setGameOverView(false, 'Game Over', false);
    redrawNextFigure();
    redraw();

    tickTimeout = setTimeout(gameLoop, getCurrentTickDelay()); // <-вот он наш злодей
};

Раз в какое-то количество миллисекунд мы вызываем геймЛуп. «Почему же не сетинтервал», – спросит неокрепших неофит. А всё по тому, что это чревато как раз внезапной неконтролируемой асинхронностью, которая приведёт к гонке за ресурсы и будут беды. Поэтому метод будет вызывать сам себя только после того, как точно закончит.

    const gameLoop = async () => {
    if (gameMode !== GameMode.PLAYING || isLoopRunning) {
        return;
    }

    isLoopRunning = true;

    if (currentFigure === null || typeof currentFigure === 'undefined') { // у нас нет фигуры? Так давай её сделаем из того, что стоит в очереди
        currentFigure = new Figure();
        currentFigure.cells = nextFigure.cells.map(row => [...row]);
        currentFigure.type = nextFigure.type;
        currentFigure.states = nextFigure.states.map(state => state.map(row => [...row]));
        currentFigure.rotationState = nextFigure.rotationState;
        currentFigure.setStartPosition();
        nextFigure.fillRandom(); // следующую фигуру херакнули в какую-нибудь новую рандомную

        redrawNextFigure();
    }

    currentFigure.moveDown(); //уронили я одну клеточку
    if (currentFigure.checkCollision()) {//пересеклисьс чем-нибудь? 
        currentFigure.moveUp(); //подняли обратно и зафиксировали с мировом пространстве
        const overflow = currentFigure.placeToField();
        currentFigure = null;

        if (overflow) {
            finishGame('top_out');
            redraw();
            isLoopRunning = false;
            return;
        }

        await field.checkLines(); // все фигуры на своих местах, можно проверить, как там у нас дела
        score += currentScore;

        scoreEl.innerText = score;

        currentScore = 0;

        field.moveLines(); // сдвинем, если надо сдвинуть
    }

    redraw();

    isLoopRunning = false;

    if (gameMode === GameMode.PLAYING) {
        tickTimeout = setTimeout(gameLoop, getCurrentTickDelay()); // снова запускаем всё сначала
    }
};

В целом, осталось только понять, что же такое фигура.

Сама геморная часть. Там больше всего буковок.

Фигура - это сущность, в которой хранится информация о том, какой она формы и как её крутить.

    class Figure {
    constructor() {
        this.cells = [
            [0],
        ];
        this.x = 2;
        this.y = -5;
        this.rotationState = 0;
    }

    moveDown = () => {
        this.y++;
    };

    moveUp = () => {
        this.y--;
    };

    moveLeft = () => {
        this.x--;
        if (this.checkCollision()) {
            this.x++;
        }
    }

    moveRight = () => {
        this.x++;
        if (this.checkCollision()) {
            this.x--;
        }
    }

    fall = () => {
        while (!this.checkCollision()) {
            this.y++;
        }
        this.moveUp();
    };

    checkCollision = () => {
        for (let i = 0; i < this.cells.length; i++) {
            for (let j = 0; j < this.cells[i].length; j++) {
                let cellX = j + this.x;
                let cellY = i + this.y;

                if (this.cells[i][j] === 0) {
                    continue;
                }

                // Проверяем границы по X всегда (независимо от Y)
                if (cellX < 0 || cellX >= field.width) {
                    return true;
                }

                // Для клеток выше видимой области не проверяем коллизии с полем
                if (cellY < 0) {
                    continue;
                }

                // Проверяем нижнюю границу и коллизии с заполненными клетками
                if (cellY >= field.height) {
                    return true;
                }

                if (field.blocks[cellY][cellX] === 1) {
                    return true;
                }
            }
        }
        return false;
    };

    rotate(withCollisions = true) {
        const from = this.rotationState;
        const to = (from + 1) % this.states.length;
        const key = `${from}>${to}`;
        const kickSet = (this.type === "I")
            ? SRS_KICKS.I[key]
            : (this.type === "O" ? SRS_KICKS.O[key] : SRS_KICKS.JLSTZ[key]);

        const originalX = this.x;
        const originalY = this.y;

        let rotated = this.states[to];

        if (!withCollisions) {
            this.cells = rotated;
            this.x = originalX;
            this.y = originalY;
            this.rotationState = to;
            return;
        }

        for (const [dx, dy] of kickSet) {
            this.cells = rotated;
            this.x = originalX + dx;
            this.y = originalY - dy;
            if (!this.checkCollision()) {
                this.rotationState = to;
                return;
            }
        }

        this.cells = this.states[from];
        this.x     = originalX;
        this.y     = originalY;
    }

    placeToField = () => {
        let overflow = false;

        for (let i = 0; i < this.cells.length; i++) {
            for (let j = 0; j < this.cells[i].length; j++) {
                let cellX = j + this.x;
                let cellY = i + this.y;

                if (this.cells[i][j] === 0) {
                    continue;
                }

                if (cellY < 0) {
                    overflow = true;
                    continue;
                }

                if (cellY >= field.height || cellX < 0 || cellX >= field.width) {
                    overflow = true;
                    continue;
                }

                field.blocks[cellY][cellX] = this.cells[i][j];
            }
        }

        return overflow;
    };

    setStartPosition = () => {
        this.x = Math.floor(field.width / 2) - Math.floor(this.cells[0].length / 2);

        if (this.type === "I" && this.rotationState === 1) {
            this.y = -2;
        } else if (this.type === "I") {
            this.y = -4;
        } else if (this.type === "O") {
            this.y = -2;
        } else if (this.rotationState === 3) {
            this.y = -2;
        } else {
            this.y = -3;
        }
    };

    fillRandom = () => {
        let figure = FIGURES[Math.floor(Math.random() * FIGURES.length)];

        this.cells         = figure.figure.states[0].map(row => [...row]);
        this.states        = figure.figure.states.map(state => state.map(row => [...row]));
        this.type          = figure.type;
        this.rotationState = 0;

        let rotateSteps = Math.floor(Math.random() * 4);
        for (let i = 0; i < rotateSteps; i++) {
            this.rotate(false);
        }
    };
}

Тут, конечно, кода дофига и надо объяснить, что тут происходит. Суть в том, что поворот в тетрисе – это прям отдельная задачка. Я её для себя упростил максимально, создав «спрайты» фигур во всех положениях заранее.

    const FIGURES = [
    {
        type: "I",
        figure:
            {
                states: [

                    [
                        [0, 1, 0, 0],
                        [0, 1, 0, 0],
                        [0, 1, 0, 0],
                        [0, 1, 0, 0],
                    ],
                    [
                        [0, 0, 0, 0],
                        [1, 1, 1, 1],
                        [0, 0, 0, 0],
                        [0, 0, 0, 0],
                    ],
                ]
            }
    },
    {
        type: "T",
        figure:
            {
                states: [
                    [
                        [0, 1, 0],
                        [0, 1, 1],
                        [0, 1, 0],
                    ],
                    [
                        [0, 0, 0],
                        [1, 1, 1],
                        [0, 1, 0],
                    ],
                    [
                        [0, 1, 0],
                        [1, 1, 0],
                        [0, 1, 0],
                    ],
                    [
                        [0, 1, 0],
                        [1, 1, 1],
                        [0, 0, 0],
                    ],
                ]
            }
    },
    {
        type: "S",
        figure:
            {
                states: [
                    [
                        [0, 1, 0],
                        [0, 1, 1],
                        [0, 0, 1],
                    ],
                    [
                        [0, 0, 0],
                        [0, 1, 1],
                        [1, 1, 0],
                    ]
                ]
            }
    },
    {
        type: "Z",
        figure:
            {
                states: [
                    [
                        [0, 0, 1],
                        [0, 1, 1],
                        [0, 1, 0],
                    ],
                    [
                        [0, 0, 0],
                        [1, 1, 0],
                        [0, 1, 1],
                    ]
                ]
            }
    },
    {
        type: "L",
        figure:
            {
                states: [
                    [
                        [0, 1, 0],
                        [0, 1, 0],
                        [0, 1, 1],
                    ],
                    [
                        [0, 0, 0],
                        [1, 1, 1],
                        [1, 0, 0],
                    ],
                    [
                        [1, 1, 0],
                        [0, 1, 0],
                        [0, 1, 0],
                    ],
                    [
                        [0, 0, 1],
                        [1, 1, 1],
                        [0, 0, 0],
                    ]
                ]
            }
    },
    {
        type: "J",
        figure:
            {
                states: [
                    [
                        [0, 1, 1],
                        [0, 1, 0],
                        [0, 1, 0],
                    ],
                    [
                        [0, 0, 0],
                        [1, 1, 1],
                        [0, 0, 1],
                    ],
                    [
                        [0, 1, 0],
                        [0, 1, 0],
                        [1, 1, 0],
                    ],
                    [
                        [1, 0, 0],
                        [1, 1, 1],
                        [0, 0, 0],
                    ]
                ]
            }
    },
    {
        type: "O",
        figure:
            {
                states: [
                    [
                        [1, 1],
                        [1, 1]
                    ]
                ]
            }
    },

];

А ещё есть такая штука как SRS – это прям общепринятый стандарт вращения фигур. Специальные таблицы описывают как необходимо проверять смещение фигур в пространстве игрового поля при переходе из одного состояния в другое, на случай столкновения со стенами или существующими блоками в момент вращения. Я, опять-таки, эту часть упростил максимально, вырезав очень много из стандарта, так как у меня, как минимум, нет вращение против часовой стрелки.

    const SRS_KICKS = {
    JLSTZ: {
        "0>1": [[0, 0], [-1, 0], [1, 0], [-1, 1], [1, 1]],
        "1>0": [[0, 0], [0, -1]],
        "1>2": [[0, 0], [0, -1]],
        "2>3": [[0, 0], [0, -1], [-1, 0], [1, 0], [1, -1]],
        "3>0": [[0, 0], [-1, 0]],
    },
    I: {
        "0>1": [[0, 0], [-1, 0], [1, 0], [-2, 0], [2, 0]],
        "1>0": [[0, 0], [0, 1], [-1, 0], [0, 2], [-2, 2]],
    },
    O: {
        "0>0": [[0, 0]]
    }
};

Логика такая: после поворота к фигуре применяются смещения по иксу и игрику по очереди из массива, сначала 0-0 (не смещается), потом, к примеру 0-1 и так далее. За идеальное состояние, которое в данный момент всех устраивает, применяется то, после которого проверка коллизии фигуры показывает, что никто ни с кем не столкнулся. Если ни один из вариантов не подошёл, значит поворот не случился.

Классические таблицы подразумевают матрицы фигур одинакового размера и квадратные по своей сути. Я тут тоже отошёл от стандарта,

Как это в итоге играется

По коду там есть у меня есть усложнение с увеличением скорости падения фигур за каждые 30 полученных очков, сохранение рекорда и прочая мишура, которая к основному алгоритму отношения уже не имеет.

Как грицца: понятно, что нифига не понятно, так что спрашивайте, господа и дамы, отвечу на недостающие вопросы)

Всем хороших игр!

Читать дальше...
5
+5 / -0
4
17
ТГ ВК
Warp_World
Warp_World Опубликовано 2 часа назад

Фигура - это сущность, в которой хранится информация о том, какой она формы и как её крутить.

после этой фразы я, наконец, осознал, что кодер я минимум херовый, а писатель - тем более) у нас так преподавать лекции по питону читал - из них стразу становилось понятно, что программирование - дело хорошее и, безусловно, нужное, но перспектива стать хорошим программистом - дело какое-то очень отдаленное и явно нам, прожженным гуманитариям, не светит) тут примерно тоже самое)

0
+0 / -0
[ Свернуть ]
VariusSoft
VariusSoft Опубликовано 2 часа назад
Ответ на Комментарий от Warp_World

Фигура - это сущность, в которой хранится информация о том, какой она формы и как её крутить.

после этой фразы я, наконец, осознал, что кодер я минимум херовый, а писатель - тем более) у нас так препод...

Ну и тетрис всё же, далеко не самая тривиальная задачка, так что это нормально)

0
+0 / -0
WseGa
WseGa Опубликовано 1 час назад

Сейчас написать тетрис очень просто. Открываешь любую ллм и говоришь ей - напиши тетрис на html canvas. :-)

0
+0 / -0
[ Свернуть ]
VariusSoft
VariusSoft Опубликовано 1 час назад
Ответ на Комментарий от WseGa

Сейчас написать тетрис очень просто. Открываешь любую ллм и говоришь ей - напиши тетрис на html canvas. :-)

Это не спортивно)

0
+0 / -0
Войти

Вход

Регистрация

Я не помню пароль

Войти через Google
Порог горячего 14
  • Porked
    Porked

    не складывается с постингом.

    +1
  • Kukabara
    Kukabara

    ну варенье из шишек мне понравилось))

    +1
  • Alida
    Alida

    О! Это зажим для ложки. Крепится на столешницу. помешала суп - чтоб не класть ложку или половник на стол - зажала птичкой)

    +2
Правила сайта
Пользовательское соглашение
О ПД
Принципы самоуправления
Нашёл ошибку?
©2026 Varius Soft