IT Notes

Тетрис на C++: Соблюдение правил

Предыдущие части:

  1. Тетрис на C++: Введение
  2. Тетрис на C++: Статическая модель
  3. Тетрис на C++: Обнаружение столкновений
  4. Тетрис на C++: Динамическая модель

Правила тетриса довольно просты:

  1. Игра начинается с появления случайного падающего элемента в верхней центральной части игрового поля. Этот элемент может контролировать игрок;
  2. Когда активный элемент касается своей нижней частью дна игрового поля или занятого блока, то через несколько игровых шагов он фиксируется и его позиция больше не подвластна игроку;
  3. Если в игровом поле образуются полностью заполненные ряды из непустых блоков, то они исчезают, а верхние ряды сдвигаются вниз;
  4. После каждого фиксирования игрового элемента появляется новый, как в начале игры;
  5. Если вновь появившийся элемент сразу имеет столкновения, то есть находится в некорректном состоянии, то игра окончена;
  6. Выиграть в тетрис, к сожалению, нельзя.

А теперь посмотрим, как все эти проверки можно записать с помощью кода C++.

Появление нового элемента

При инициализации модели активный игровой элемент находится в нейтральном состоянии с пустой внутренней матрицей (см. Паттерн Null Object). Такая проверка выполняется с помощью функции-члена isNull():

bool TetrisItem::isNull() const {
    return m_matrix.empty();
}

Формирование нового элемента выполняем в начале функции-члена doStep(), которая вызывается для каждого игрового шага (по таймеру из Контроллера):

void TetrisModel::doStep() {
    if( m_activeItem.isNull() ) {
        m_activeItem = TetrisItem::generateRandom();
        int xPoints = blocksToPoints( getWidthBlocks() / 2 );
        if( m_activeItem.getSizeBlocks() % 2 == 1 ) {
            // Если элемент состоит из нечетного числа блоков, то выравниваем его по сетке:
            xPoints += HALF_BLOCK_SIZE;
        }
    }

    // А это осталось еще с прошлого раза:
    TetrisItem item = m_activeItem;
    item.setPosition( m_activeItem.getXPoints(), m_activeItem.getYPoints() + m_speed );
    if( !hasCollisions( item ) ) {
        m_activeItem = item;
    } else {
        while( hasCollisions( item ) ) {
            item.setPosition( item.getXPoints(), item.getYPoints() - 1 );
        }
        m_activeItem = item;
    }
}

Формирование случайного элемента осуществляется в функции TetrisItem::generateRandom():

TetrisItem TetrisItem::generateRandom() {
    // Для краткости не будем записывать все возможные элементы
    static const std::vector< TetrisItem > ITEMS = {
        TetrisItem( {
            { 1, 1 },
            { 1, 1 },
        } ),
        // И т.д.
    };
    int type = rand() % ITEMS.size();
    return ITEMS[ type ];
}

Проверка Game Over

В функцию doStep() удобно добавить и проверку на окончание игры:

void TetrisModel::doStep() {
    if( m_activeItem.isNull() ) {
        // Убрано для краткости…

        if( hasCollisions( m_activeItem ) ) {
            // Если элемент сразу имеет столкновения, то игра окончена
            m_gameOver = true;
        }
    }

    if( isGameOver() ) {
        // Если игра окончена, то продолжать нет смысла
        return;
    }

    // Убрано для краткости…
}

Спросить у модели о том, не окончена ли игра, можно с помощью isGameOver():

bool TetrisModel::isGameOver() const {
    return m_gameOver;
}

Активный элемент на дне

Когда нижняя часть активного элемента во что-то упирается, то мы позволяем ему немного "поползать":

void TetrisModel::doStep() {
    if( m_activeItem.isNull() ) {
        // Сбрасываем счетчик касаний при появлении нового элемента
        m_itemBottomTouchCounter = 0;
        m_activeItem = TetrisItem::generateRandom();
        int xPoints = blocksToPoints( getWidthBlocks() / 2 );
        if( m_activeItem.getSizeBlocks() % 2 == 1 ) {
            // Если элемент состоит из нечетного числа блоков, то выравниваем его по сетке:
            xPoints += HALF_BLOCK_SIZE;
        }
        m_activeItem.setPosition( xPoints, 0 );
        if( hasCollisions( m_activeItem ) ) {
            m_gameOver = true;
        }
    }

    if( isGameOver() ) {
        return;
    }

    TetrisItem item = m_activeItem;
    item.setPosition( m_activeItem.getXPoints(), m_activeItem.getYPoints() + m_speed );
    if( !hasCollisions( item ) ) {
        m_activeItem = item;
        // Если столкновений нет, то сбрасываем счетчик касаний
        m_itemBottomTouchCounter = 0;
    } else {
        while( hasCollisions( item ) ) {
            item.setPosition( item.getXPoints(), item.getYPoints() - 1 );
        }
        if( MAX_TOUCH_COUNT < m_itemBottomTouchCounter ) {
            // Если количество касаний превысило некий разумный предел, то элемент фиксируется
            m_activeItem = TetrisItem();
        } else {
            m_activeItem = item;
            // Иначе значение счетчика касаний наращивается
            ++m_itemBottomTouchCounter;
        }
    }
}

tetris-bottom-crawl

Для поддержки функции перемещения по дну нам пришлось добавить счетчик касаний. Каждый раз когда элемент касается непустого блока своей нижней частью, значение счетчика увеличивается на единицу. Как только значение счетчика достигает определенной границы, элемент фиксируется (точнее, пока что просто исчезает). Если же игрок перемещает элемент (влево или вправо) таким образом, что он вновь начинает падать, то значение счетчика сбрасывается.

Фиксирование активного элемента

Нас не устраивает, что сейчас активный элемент просто исчезает после падения. Сделаем так, чтобы все его блоки стали частью игрового поля:

void TetrisModel::doStep() {
    // Убрано для краткости…

    if( !hasCollisions( item ) ) {
        // Убрано для краткости…
    } else {
        // Убрано для краткости…

        if( MAX_TOUCH_COUNT < m_itemBottomTouchCounter ) {
            m_activeItem = TetrisItem();
            for( int xBlocks = 0; xBlocks < item.getSizeBlocks(); ++xBlocks ) {
                for( int yBlocks = 0; yBlocks < item.getSizeBlocks(); ++yBlocks ) {
                    // Для каждого блока элемента
                    int blockType = item.getBlockType( xBlocks, yBlocks );
                    if( blockType != 0 ) {
                        // Если блок не пустой
                        int xPoints = item.getBlockXPoints( xBlocks );
                        int yPoints = item.getBlockYPoints( yBlocks );
                        // Фиксируем тип блока в ячейке матрицы игрового поля
                        m_fieldMatrix[ yPoints / BLOCK_SIZE ][  xPoints / BLOCK_SIZE ] = blockType;
                    }
                }
            }
        } else {
            // Убрано для краткости…
        }
    }
}

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

Очистка заполненных рядов и повышение сложности

Модель практически закончена. Остается добавить всего несколько заключительных штрихов. Вот алгоритм очистки игрового поля от заполненных рядов:

void TetrisModel::clean() {
    for( int i = m_heightBlocks - 1; i >= 0; --i ) {
        // Для каждого ряда начиная с последнего
        // Считаем количество заполненных блоков
        int counter = std::accumulate(
            m_fieldMatrix[ i ].begin(),
            m_fieldMatrix[ i ].end(),
            0,
            []( int a, int b ) { return ( b == 0 ) ? a : a + 1; }
        );

        if( counter == 0 ) {
            // Если все блоки пустые, то выше идти смысла нет
            return;
        } else if( counter == getWidthBlocks() ) {
            // Иначе если все блоки заполнены, то удаляем ряд…
            m_fieldMatrix.erase( m_fieldMatrix.begin() + i );
            std::vector< int > v( getWidthBlocks() );
            // … и вставляем пустой в самый верх
            m_fieldMatrix.insert( m_fieldMatrix.begin(), v );
            // А еще наращиваем количество набранных очков
            incScore();
            ++i;
        }
    }
}

Вызывать clean() нужно сразу же после кода фиксирования элемента:

void TetrisModel::doStep() {
    // Убрано для краткости…

    if( !hasCollisions( item ) ) {
        // Убрано для краткости…
    } else {
        // Убрано для краткости…

        if( MAX_TOUCH_COUNT < m_itemBottomTouchCounter ) {
            // Убрано для краткости…

            // Чистим игровое поле
            clean();
        } else {
            // Убрано для краткости…
        }
    }
}

tetris-clean

За каждый убранный ряд количество набранных очков увеличивается на единицу. За каждые набранные 10 очков сложность игры возрастает (путем наращивания скорости на единицу):

void TetrisModel::incScore() {
    ++m_score;
    if( m_score % SCORE_COUNT_FOR_NEXT_LEVEL == 0 ) {
        incSpeed();
    }
}

void TetrisModel::incSpeed() {
    if( m_speed < MAX_SPEED ) {
        ++m_speed;
    }
}

На самом деле, функция incScore() получилась не самой удачной (см. Принцип единой ответственности): она имеет побочный эффект (увеличивает скорость). Однако для такой простой игры это оказалось не критично. Возможно, что в более сложном проекте пришлось бы ее переработать.

Последняя мелочь

Скорость падения элемента может меняется не только в результате вызова incSpeed(), но и по воле игрока. Для этого усовершенствуем нашу модель следующим образом:

// Включает ускоренный режим
void TetrisModel::startDrop() {
    m_dropEnabled = true;
}

// Отключает ускоренный режим
void TetrisModel::stopDrop() {
    m_dropEnabled = false;
}

void TetrisModel::doStep() {
    // Убрано для краткости…

    TetrisItem item = m_activeItem;
    int speed = m_dropEnabled ? MAX_SPEED : m_speed;
    item.setPosition( m_activeItem.getXPoints(), m_activeItem.getYPoints() + speed );

    // Убрано для краткости…
}

Заключение

На этом мы завершаем знакомство с Моделью игры тетрис. А в следующий раз переходим к краткому обзору реализаций Представления и Контроллера

Похожие публикации