IT Notes

Тетрис на C++: Представление и Контроллер

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

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

В прошлый раз мы закончили с Моделью тетриса. Однако чтобы получить игру, одной логики мало. Требуется взаимодействие с пользователем и координация действий приложения. Первую задачу решает Представление, а вторую - Контроллер. Ими мы и займемся.

Представление для тетриса

Представление находится на передовой приложения. Именно с ним взаимодействует пользователь. Представление обеспечивает:

  1. Визуализацию данных Модели;
  2. Перенаправление действий пользователя Контроллеру.

tetris-mvc

Чтобы соблюдать чистоту паттерна MVC, придерживайтесь простого правила: Представление не меняет состояние Модели. Поэтому если заметили, что Представление использует не-const функцию-член Модели, то что-то не так.

Визуализация данных Модели

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

Отрисовку Модели выполняем с помощью QPainter в обработчике paintEvent():

void TetrisView::paintEvent( QPaintEvent* ) {
    QPainter painter( this );
    // Очищаем игровое поле
    painter.fillRect( 0, 0, m_width, m_height, BACKGROUND_COLOR );

    if( DEBUG ) {
        // Рисуем сетку игрового поля при включенном режиме отладки
        painter.setPen( DEBUG_GRID_COLOR );
        for( int x = BLOCK_SIZE_PIXELS; x < m_width; x += BLOCK_SIZE_PIXELS ) {
            painter.drawLine( x, 0, x, m_height );
        }
        for( int y = BLOCK_SIZE_PIXELS; y < m_height; y += BLOCK_SIZE_PIXELS ) {
            painter.drawLine( 0, y, m_width, y );
        }
    }

    // Рисуем блоки игрового поля
    for( int x = 0; x < m_model.getWidthBlocks(); ++x ) {
        for( int y = 0; y < m_model.getHeightBlocks(); ++y ) {
            drawBlock(
                blocksToPoints( x ) + HALF_BLOCK_SIZE,
                blocksToPoints( y ) + HALF_BLOCK_SIZE,
                m_model.getBlockType( x, y ),
                &painter
            );
        }
    }

    // Рисуем активный элемент
    const TetrisItem& item = m_model.getItem();
    for( int x = 0; x < item.getSizeBlocks(); ++x ) {
        for( int y = 0; y < item.getSizeBlocks(); ++y ) {
            drawBlock(
                item.getBlockXPoints( x ),
                item.getBlockYPoints( y ),
                item.getBlockType( x, y ),
                &painter
            );
        }
    }
}

void TetrisView::drawBlock( int xPoints, int yPoints, int type , QPainter* painter ) {
    static const std::vector< QColor > COLOR_TABLE = {
        Qt::white,
        Qt::yellow,
        Qt::green,
        Qt::red,
        Qt::cyan,
        Qt::magenta,
        Qt::lightGray
    };

    if( type <= 0 || COLOR_TABLE.size() < type ) {
        return;
    }

    int xPixels = modelPointsToPixels( xPoints ) - HALF_BLOCK_SIZE_PIXELS;
    int yPixels = modelPointsToPixels( yPoints ) - HALF_BLOCK_SIZE_PIXELS;
    painter->fillRect( xPixels, yPixels, BLOCK_SIZE_PIXELS, BLOCK_SIZE_PIXELS, COLOR_TABLE[ type - 1 ] );
}

Реализация Представления, отвечающая за визуализацию, решает 3 подзадачи:

  1. Рисует сетку игрового поля, полезную в режиме отладки;
  2. Рисует каждый блок игрового поля;
  3. Рисует каждый блок активного элемента.

Для рисования блоков используется отдельная функция drawBlock(), которая принимает координаты середины блока в точках, тип блока и указатель на QPainter. Каждому типу блока ставится в соответствие некоторый цвет. Проще всего для этого использовать таблицу цветов, представленную вектором. В этом случае для типа блока type цвет выбирается следующим образом: COLOR_TABLE[ type - 1 ].

Одним из способов вызова paintEvent() является слот repaint(). Однако не будем привязываться к нему жестко и создадим свою обертку:

void TetrisView::refresh() {
    repaint();
}

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

Вызов refresh() в нужные моменты обеспечит Контроллер (альтернатива - паттерн Наблюдатель в рамках Модели). При этом мы не мелочимся, а перерисовываем все поле каждый раз.

Перенаправление действий пользователя Контроллеру

Тетрис - не самая сложная игра в плане управления. Свяжем события нажатий нужных клавиш с соответствующими обработчиками Контроллера:

void TetrisView::keyPressEvent( QKeyEvent* e ) {
    switch( e->key() ) {
    case Qt::Key_Left:
        m_controller->onMoveLeft();
        break;
    case Qt::Key_Right:
        m_controller->onMoveRight();
        break;
    case Qt::Key_Up:
        m_controller->onRotate();
        break;
    case Qt::Key_Down:
        m_controller->onDropEnabled( true );
        break;
    case Qt::Key_Space:
        m_controller->onTogglePause();
        break;
    case Qt::Key_Escape:
        m_controller->onStart();
        break;
    default:
        QWidget::keyPressEvent( e );
    }
}

void TetrisView::keyReleaseEvent( QKeyEvent* e ) {
    switch( e->key() ) {
    case Qt::Key_Down:
        m_controller->onDropEnabled( false );
        break;
    default:
        QWidget::keyReleaseEvent( e );
    }
}

Все изменения в Модели должны происходить только через Контроллер.

Контроллер для тетриса

Контроллер в рамках MVC представляет собой прослойку между Представлением и Моделью, когда речь заходит о действиях пользователя. При правильном проектировании реализация функций Контроллера представляется тривиальной задачей:

TetrisController::TetrisController( TetrisModel* model , TetrisView* view, QObject* parent ) :
    QObject( parent ), m_model( model ), m_view( view ) {
    connect( &m_timer, SIGNAL( timeout() ), SLOT( onStep() ) );
}

void TetrisController::onStart() {
    m_model->resetGame();
    onResume();
}

void TetrisController::onStep() {
    m_model->doStep();
    m_view->refresh();
    if( m_model->isGameOver() ) {
        qDebug() << m_model->getScore();
        m_model->resetGame();
    }
}

void TetrisController::onPause() {
    m_timer.stop();
}

void TetrisController::onResume() {
    m_timer.start( STEP_TIME_INTERVAL );
}

void TetrisController::onMoveLeft() {
    onAction( &TetrisModel::moveItemLeft );
}

void TetrisController::onMoveRight() {
    onAction( &TetrisModel::moveItemRight );
}

void TetrisController::onRotate() {
    onAction( &TetrisModel::rotateItem );
}

void TetrisController::onDropEnabled( bool enabled ) {
    onAction( enabled ? &TetrisModel::startDrop : &TetrisModel::stopDrop );
}

void TetrisController::onTogglePause() {
    m_timer.isActive() ? onPause() : onResume();
}

void TetrisController::onAction( void ( TetrisModel::*action )() ) {
    if( !m_timer.isActive() ) {
        return;
    }

    ( m_model->*action )();
    m_view->refresh();
}

Здесь следует обратить внимание на следующие моменты:

  • Вызов onStep() происходит по событию таймера QTimer. В onStep() выполняется обновление состояния модели с помощью doStep(). Таким образом, в Модели отсутствует понятие паузы. Пауза достигается путем остановки таймера. С одной стороны, это не очень хорошо, поскольку Контроллеру не желательно иметь собственного состояния. С другой стороны, паттерны проектирования не являются жесткими решениями, поэтому всегда в первую очередь руководствуйтесь здравым смыслом;
  • Также в onStep() проверяется не окончена ли игра. Если игра окончена, то на консоль выводится счет, и игра перезапускается;
  • Обработка игровых действий выполняется с помощью закрытой функции onAction(), которая принимает указатель на функцию-член Модели. Это удобно, поскольку в обработке действий много общего. Например, если игра приостановлена, то действие выполнять не требуется. К тому же, после выполнения действия не помешает обновить Представление, чтобы оно отражало актуальное состояние Модели.

Заключение

На этом мы завершаем цикл статей, посвященных разработке тетриса на C++. Нельзя сказать, что мы реализовали законченную игру. Ей не хватает еще многих элементов: таблицы лучших результатов, графических и звуковых эффектов, и т.д. Однако то, что у нас получилось, является хорошей основой. А принятые проектные решения обеспечивают относительно простое расширение функционала.

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