Статья
Версия для печати
Обсудить на форуме
Создание собственного графического элемента управления с использованием библиотеки MFC.
Часть 3.


Автор: Алексей1153.
Дата написания: 20.12.2009.
Права на статью принадлежат автору и Клубу программистов «Весельчак У».

Содержание.


Становимся родителями.

Подошло время самостоятельности нашего класса CMyControl. Теперь он сам станет родительским классом. Как видите, весь код, который мы написали в первых двух частях, довольно общий и может содержаться в любом графическом ЭУ. Поэтому, недолго думая, будем производить от CMyControl потомков, а сам класс ещё будем дополнять. Например, точно понадобится сделать функцию CMyControl::PaintRoom виртуальной, чтобы производный класс мог рисовать в области Room.

Код:
class CMyControl:public CStatic
{
...
...
public:
virtual void PaintRoom(CDC& dc,const CRect& RoomRect);
...
...
};

Кстати, в потомке именно поэтому даже не придётся обрабатывать сообщение WM_PAINT.

Реверси.

Думал, что бы такое сделать, оформились три мысли, но две из них более безумны, чем третья (если время будет — я их тоже реализую и приложу :) ). Сделаем известную игру «Реверси». Понимаю, что всем уже надоел этой игрой, но предыдущая реализация мне очень не понравилась, всегда хотелось переписать, поэтому воспользуюсь случаем.

Правила игры.

Правила игры простые, как всё гениальное. Материал цитаты с правилами взят отсюда: Википедия — Реверси.
В игре используется квадратная доска размером 8 * 8 клеток (все клетки могут быть одного цвета) и 64 специальные фишки, окрашенные с разных сторон в контрастные цвета, например, в белый и чёрный. Клетки доски нумеруются от верхнего левого угла: вертикали — латинскими буквами, горизонтали — цифрами. Один из игроков играет белыми, другой — чёрными. Делая ход, игрок ставит фишку на клетку доски «своим» цветом вверх.
В начале игры в центр доски выставляются 4 фишки: чёрные на d5 и e4, белые на d4 и e5.
  • Первый ход делают чёрные. Далее игроки ходят по очереди.
  • Делая ход, игрок должен поставить свою фишку на одну из клеток доски таким образом, чтобы между этой поставленной фишкой и одной из имеющихся уже на доске фишек его цвета находился непрерывный ряд фишек соперника, горизонтальный, вертикальный или диагональный (другими словами, чтобы непрерывный ряд фишек соперника оказался «закрыт» фишками игрока с двух сторон). Все фишки соперника, входящие в «закрытый» на этом ходу ряд, переворачиваются на другую сторону (меняют цвет) и переходят к ходившему игроку.
  • Если в результате одного хода «закрывается» одновременно более одного ряда фишек противника, то переворачиваются все фишки, оказавшиеся на всех «закрытых» рядах.
  • Игрок вправе выбирать любой из возможных для него ходов. Если игрок имеет возможные ходы, он не может отказаться от хода. Если игрок не имеет допустимых ходов, то ход передаётся сопернику.
  • Игра прекращается, когда на доску выставлены все фишки или когда ни один из игроков не может сделать ход. По окончании игры проводится подсчёт фишек каждого цвета, и игрок, чьих фишек на доске выставлено больше, объявляется победителем. В случае равенства количества фишек засчитывается ничья.

Класс CReversi:public CMyControl.

Добавим в проект файлы класса нового ЭУ CReversi

CReversi.h:
Код:
#pragma once

#include "CMyControl.h"

class CReversi:public CMyControl
{
//DECLARE_DYNAMIC(CReversi)
public:
CReversi();
~CReversi();
virtual void PaintRoom(CDC& dc,const CRect& RoomRect);
};

CReversi.cpp:
Код:
#include "stdafx.h"
#include "CReversi.h"

//место для  других include


#ifdef _DEBUG
#define new DEBUG_NEW
#endif


//место для кода реализации


CReversi::CReversi()
{
}

CReversi::~CReversi()
{
}

void CReversi::PaintRoom(CDC& dc,const CRect& RoomRect)
{
//пока что вызываем рисовалку родителя
CMyControl::PaintRoom(dc,RoomRect);
}

А также сменим класс у переменной главного диалога, ведь теперь наш ЭУ на диалоге стал игрой, а не просто окошком с меню.

Код:
//#include "cmycontrol.h"
#include "CReversi.h"

// диалоговое окно CMyGraphControlDlg
class CMyGraphControlDlg : public CDialog
{
...
...
CReversi m_IDC_MY1;
...
...
};

Если сейчас запустить проект, мы не увидим на экране никаких изменений, потому что класс CReversi пока что полностью идентичен родителю.
Мы, конечно, сейчас немного отвлечёмся от основной темы статьи — графики, однако не считаю лишним дать пояснения к коду игры. Самые нетерпеливые могут пропустить часть текста и начать чтение с заголовка «Борьба с мерцанием при перерисовке и обрезка области графики».

Разделяем обязанности.

Всю задачу «игра Реверси» разделим на несколько задач, реализованных в отдельных классах. Будем использовать следующие компоненты (этот список я позже допишу, не думайте, что он (список) прямо вот так взял и весь появился сразу )

классописание
CReversi::CCellКлетка доски.
CReversi::CBoardДоска 8 * 8 клеток.
CReversi::CPlayerИгрок.
CReversi::CGameplayОбъект-геймплей и класс-менеджер в одном флаконе.

Код классов игры, приведённый ниже, может отличаться от кода в реальном проекте. Думаю, причины понятны: я пишу этот код сейчас, а потом он может поменяться, могут быть исправлены ошибки, могу просто забыть скопировать обновлённый код из проекта в статью. Поэтому окончательный вариант кода (собственно, весь проект) можно будет найти в конце статьи. А сейчас, в тексте, код больше нужен для того, чтобы показать состав и интерфейс классов.
Объявляем переменные, воспользовавшись паттерном проектирования pimpl (pointer to implementation) с целью не загромождать заголовочный файл класса CReversi.

CReversi.h:
Код:
class CReversi:public CMyControl
{
class CCell;
class CBoard;
class CPlayer;
class CGameplay;

//pimpl-члены класса (pointer to implementation)
CBoard* m_pBoard; //доска
CGameplay* m_pGameplay; //геймплей
...
...

CReversi.cpp:
Код:
...
//константы размеров доски и окружения
enum
{
e_lef_info_wid=10,//пространство слева от доски
e_rig_info_wid=50,//пространство справа от доски
e_top_info_hig=10,//пространство сверху от доски
e_bot_info_hig=10,//пространство снизу от доски

e_wid_boardLines=1,//толщина линий сетки доски (лучше не менять)
};

//константы цветов
enum
{
e_color_boardBackGround=RGB(0,128,0),//цвет фона доски
e_color_boardLines=RGB(90,200,90),//цвет сетки доски
};

class CReversi::CCell
{
...
};

class CReversi::CBoard
{
...
};

class CReversi::CPlayer
{
...
};


class CReversi::CGameplay
{
CPlayer m_P1;
CPlayer m_P2;
...
...
};

CReversi::CReversi()
{
//создаём pimpl-члены класса
m_pBoard=new CBoard;
m_pGameplay=new CGameplay;

...
...
}

CReversi::~CReversi()
{
...
...

//удаляем pimpl-члены класса
if(m_pGameplay)delete m_pGameplay; m_pGameplay=0;
if(m_pBoard)delete m_pBoard; m_pBoard=0;
}

...
...

Константы enum я решил тоже поместить в cpp-файле, так как функции вычисления прямоугольников будут расположены в классе CBoard, который определён как раз в cpp. А использование данных констант вне наших классов и не предполагается.

Клетка.

Клетка доски.

Код:
//клетка доски
class CReversi::CCell
{
public:
enum ee_types
{
e_null_,
e_empty,
e_black,
e_white,
};

private:
BYTE m_byType;//тип содержимого клетки

public:
CCell(CCell::ee_types t=e_empty)
:m_byType(t)
{
}

void SetType(CCell::ee_types t)
{
if(m_byType!=e_null_)
{
m_byType=t;
}
}

void SetNull_(){SetType(e_null_);}
void SetEmpty(){SetType(e_empty);}
void SetBlack(){SetType(e_black);}
void SetWhite(){SetType(e_white);}
bool IsNull_()const{return m_byType==e_null_;}
bool IsEmpty()const{return m_byType==e_empty;}
bool IsBlack()const{return m_byType==e_black;}
bool IsWhite()const{return m_byType==e_white;}

bool Contains_A_Bone()const{return IsBlack() ||IsWhite();}

bool IsThisType(CCell::ee_types testType)const
{
return m_byType==testType;
}

bool IsOpponentOf(CCell::ee_types testType)const
{
return m_byType==st_GetOpponentTypeFor(testType);
}

static CCell::ee_types st_GetOpponentTypeFor(CCell::ee_types testType)
{
if(testType==e_white)return e_black;
if(testType==e_black)return e_white;
return e_null_;
}
};

Клетка может иметь один из четырёх типов:
  • e_null_ — клетка вне доски. Если установить тип клетки e_null_, то сменить тип на другой уже нельзя.
  • e_empty — клетка пустая.
  • e_black — в клетке чёрная фишка.
  • e_white — в клетке белая фишка.
Тип e_null_ введён для удобства, с его помощью избегаем множества проверок выхода за край доски, что в целом положительно сказалось на чтении кода функции для совершения очередного хода —  CReversi::CBoard::SetNewBone(). Без этого типа сия функция была совсем уж монстровата и чревата ошибками. Также этот тип позволил ввести функцию доски CReversi::CBoard::Cell(), всегда безопасно возвращающую ссылку на клетку, независимо от корректности переданных в функцию координат.

Доска.

Доска содержит массив из 64 клеток ( 8 * 8 ), а также набор методов для работы с доской.

Код:
//константы размеров доски и окружения
enum
{
e_lef_info_wid=10,//пространство слева от доски
e_rig_info_wid=50,//пространство справа от доски
e_top_info_hig=10,//пространство сверху от доски
e_bot_info_hig=10,//пространство снизу от доски

e_wid_boardLines=1,//толщина линий сетки доски (лучше не менять)
};

//доска
class CReversi::CBoard
{
enum ee_sizes
{
e_xmax=8,
e_ymax=8,
};

CCell m_NullCell;
CCell m_Cells[e_xmax*e_ymax];

public:
typedef void(*tdf_cellGotNewType)
(CWnd* pw, CBoard& boardToChange, const DWORD x_zb,const DWORD y_zb);

CBoard(const CBoard& src)
{
(*this)=src;
}

void operator=(const CBoard& src)
{
m_NullCell=src.m_NullCell;

for(int i=0; i<e_xmax*e_ymax; i++)
{
m_Cells[i]=src.m_Cells[i];
}
}

//получить ссылку на клетку. Если индекс за пределами доски,
//вернётся ссылка на null-клетку
CCell& Cell(DWORD x_zb,DWORD y_zb)
{
if(x_zb>=e_xmax || y_zb>=e_ymax)
{
return m_NullCell;
}
else
{
return m_Cells[e_xmax*x_zb+y_zb];
}
}

const CCell& Cell(DWORD x_zb,DWORD y_zb)const
{
return Cell(x_zb,y_zb);
}

CBoard()
{
m_NullCell.SetNull_();
}

//начальное состояние доски
//4 фишки в центре доски
void SetToBeginState()
{
Cell(3,3).SetWhite();
Cell(4,4).SetWhite();
Cell(3,4).SetBlack();
Cell(4,3).SetBlack();
}

//сделать ход (поставить фишку)
//bWhite == true  — белая фишка
// == false — чёрная фишка
//bTestMode — если true, то фишки доски реально не меняются, происходит лишь
//моделирование хода
//Возвращает количество зажатых фишек. Если 0 — то ход невозможен
DWORD SetNewBone(
const DWORD x_zb,
const DWORD y_zb,
const bool bSetWhiteBone,
CWnd* pWinForCallBack=0,
CBoard::tdf_cellGotNewType cb=0
)
{
DWORD dwdCommonCount=0;//количество зажатых фишек

CCell& aimCell=Cell(x_zb,y_zb);

//убеждаемся, что клетка ещё пуста
if(!aimCell.IsEmpty())return 0;

//цвет фишки, которую хотят поставить
const CCell::ee_types AlliedType=
(bSetWhiteBone?CCell::e_white:CCell::e_black);

//цвет фишек врага
const CCell::ee_types EnemyType_=
CCell::st_GetOpponentTypeFor(AlliedType);

//ставим фишку
aimCell.SetType(AlliedType);
if(cb)cb(pWinForCallBack,*this,x_zb,y_zb);

//сканируем 8 направлений на предмет зажимания
//фишек врага между своими
DWORD dx,dy;
for(dx=-1; dx!=2; dx++)
{
for(dy=-1; dy!=2; dy++)
{
//в этом направлении ещё не встречено фишки врага
bool bWasEnemy_=false;

//в этом направлении ещё не встречено своей фишки
bool bWasAllied=false;

//количество зажатых фишек врага в этом направлении
DWORD dwdDirEnemyCount=0;

//рассматриваем это направление, прибавляя
DWORD x,y;
x=x_zb+dx;
y=y_zb+dy;
for(;;x+=dx, y+=dy)
{
//проверяем клетку, на которую только что сместились
CCell& currcell=Cell(x,y);

if(currcell.IsThisType(EnemyType_))
{
//отмечаем, что была вражеская фишка
bWasEnemy_=true;
dwdDirEnemyCount++;

//шагаем дальше
}
else
if(currcell.IsThisType(AlliedType))
{
//отмечаем, что встречена "своя" фишка
bWasAllied=true;

//возможно, зажали вражеские фишки
if(dwdDirEnemyCount>0
&& bWasEnemy_ && bWasAllied)
{
//зажали dwdDirEnemyCount фишек врага
dwdCommonCount+=dwdDirEnemyCount;

//переворачиваем зажатые фишки

//состав направления таков:
//1)первая  фишка==Cell(x_zb,y_zb),
//2)dwdDirEnemyCount вражеских
//3)последняя == Cell(x,y)

DWORD xRev=x_zb+dx;
DWORD yRev=y_zb+dy;
for(;xRev!=x && yRev!=y; xRev+=dx, yRev+=dy)
{
Cell(xRev,yRev).SetType(AlliedType);
if(cb)cb(pWinForCallBack,*this,xRev,yRev);
}
}

//направление пройдено
break;
}
else
{
//направление пройдено
break;
}
}//for
}//for
}//for

if(!dwdCommonCount)
{
//если ничего не зажато, исправляем поставленную было фишку
//убираем фишку
aimCell.SetEmpty();
if(cb)cb(pWinForCallBack,*this,x_zb,y_zb);
}

return dwdCommonCount;
}

void GetStatistics(
DWORD* pdwdBlackCount=0,
DWORD* pdwdWhiteCount=0,
bool* pbBoardIsFull_=0)const
{
if(pdwdBlackCount)*pdwdBlackCount=0;
if(pdwdWhiteCount)*pdwdWhiteCount=0;
if(pbBoardIsFull_)*pbBoardIsFull_=false;
DWORD dwdCommonCount=0;

DWORD x,y;
for(x=0; x<e_xmax; x++)
{
for(y=0; y<e_ymax; y++)
{
const CCell& C=Cell(x,y);

if(C.IsBlack())
{
dwdCommonCount++;
if(pdwdBlackCount)
{
(*pdwdWhiteCount)++;
}
}
else
if(C.IsWhite())
{
dwdCommonCount++;
if(pdwdWhiteCount)
{
(*pdwdWhiteCount)++;
}
}
}
}

if(pbBoardIsFull_)
{
if(dwdCommonCount)
{
*pbBoardIsFull_=true;
}
}
}

bool IsItPossibleToSetNewBone(const bool bSetWhiteBone)const
{
CBoard testBoard;
testBoard=*this;

DWORD x,y;
for(x=0; x<e_xmax; x++)
{
for(y=0; y<e_ymax; y++)
{
if(testBoard.SetNewBone(x,y,bSetWhiteBone))
{
return true;
}
}
}

return false;
}

void PaintBoard(CDC& dc,const CRect& BoardRect)
{
...
}

//вычисление прямоугольника клетки в прямоугольнике доски.
//Площадь прямоугольника доски может быть израсходована не вся-
// это учитывать при закраске прямоугольника доски
static bool st_GetCellRect(DWORD x_zb,DWORD y_zb,const CRect& BoardRect,CRect& CellRect)
{
int w=(BoardRect.Width()-e_wid_boardLines)/e_xmax;
int h=(BoardRect.Height()-e_wid_boardLines)/e_ymax;

if(!w || !h)
{
CellRect=CRect(0,0,0,0);
return false;
}

CellRect.left  =BoardRect.left +w*x_zb;
CellRect.right =CellRect.left  +w-1;
CellRect.top   =BoardRect.top  +h*y_zb;
CellRect.bottom=CellRect.top   +h-1;

return true;
}

//определение прямоугольника доски
//если вернёт false, значит прямоугольник доски равен CRect(0,0,0,0))
//прямоугольник принудительно делается квадратным -
//путём уменьшения более длинной стороны прямоугольника.
//в *pSpareRightSpace вернутся размеры "обрезков" снизу и справа
static bool st_GetBoardRect(const CRect& LayerRect,CRect& BoardRect,CSize* pBotRigSpareSize=0)
{
if(pBotRigSpareSize)*pBotRigSpareSize=CSize(0,0);
BoardRect=CRect(0,0,0,0);

if(LayerRect.Width ()<=e_lef_info_wid+e_rig_info_wid)return false;
if(LayerRect.Height()<=e_top_info_hig+e_bot_info_hig)return false;

//предварительный размер
CRect PreviewBoardRect=CRect
(
LayerRect.left  +e_lef_info_wid,
LayerRect.top   +e_top_info_hig,
LayerRect.right -e_rig_info_wid-1,
LayerRect.bottom-e_bot_info_hig-1
);

//делаем размер квадратным, уменьшая более длинную сторону
//и заодно находим величину обрезков до квадрата
int diff=0;
if(PreviewBoardRect.Width()>=PreviewBoardRect.Height())
{
diff=PreviewBoardRect.Width()-PreviewBoardRect.Height();
PreviewBoardRect.right-=diff;
if(pBotRigSpareSize)pBotRigSpareSize->cx=diff;
}
else
{
diff=PreviewBoardRect.Height()-PreviewBoardRect.Width();
PreviewBoardRect.bottom-=diff;
if(pBotRigSpareSize)pBotRigSpareSize->cy=diff;
}

//находим скорректированный размер
CRect BotRigCellRect;
if(!st_GetCellRect(e_xmax-1,e_ymax-1,PreviewBoardRect,BotRigCellRect))return false;
BoardRect=PreviewBoardRect;
BoardRect.right=BotRigCellRect.right+e_wid_boardLines;
BoardRect.bottom=BotRigCellRect.bottom+e_wid_boardLines;

return true;
}
};

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

Код:
static bool st_GetBoardRect(const CRect& LayerRect,CRect& BoardRect,CSize* pBotRigSpareSize=0)
static bool st_GetCellRect(DWORD x_zb,DWORD y_zb,const CRect& BoardRect,CRect& CellRect)

Метод st_GetBoardRect определяет прямоугольник доски по следующему алгоритму:
  • Проверка того факта, хватает ли вообще места для доски после отнимания размера пространств, заданных константами e_lef_info_wid, e_rig_info_wid, e_top_info_hig, e_bot_info_hig.
  • Если места хватает, считаем предварительный размер PreviewBoardRect, потом обрезаем его до квадрата. При этом сохраняем размер обрезка в выходной параметр *pBotRigSpareSize — эта информация пригодится, если нужно корректировать размер главного окна (его нужно уменьшить на величину обрезка — тогда корректировки квадратности исчезнут).
  • Размеры доски (без учёта завершающих линий сетки) нужно сделать кратными размерам клеток. Для этого определяем прямоугольник правой нижней клетки, подгоняем прямоугольник доски по нему (и не забываем прибавить размеры обрезков к уже имеющимся).

Метод st_GetCellRect определяет прямоугольник клетки по максимальному прямоугольнику доски и индексам клетки. Алгоритм работы метода:
  • Находим высоту и ширину одной клетки (делим размеры доски на максимальное количество клеток по этим сторонам) и округляем, отбрасывая дробную часть.
  • По индексам определяем координаты прямоугольника клетки.

Также имеется метод отрисовки доски (о нём поговорим попозже).

Код:
void PaintBoard(CDC& dc,const CRect& BoardRect)

Игрок.

Игрок делает ходы на доске (но не сам напрямую, а через класс геймплея). Класс игрока выглядит следующим образом:

Код:
//игрок
class CReversi::CPlayer
{
public:
enum ee_types
{
e_null_, //пустой тип — если установить, то никогда не меняется.
e_empty, //тип не задан
e_human, //человек
e_comp_, //AI
e_net__, //игрок подключается через сеть
};

private:
//тип игрока
ee_types m_Type;
//тип фишек игрока
CCell m_PlrCellType;

public:

CPlayer(CPlayer::ee_types t=e_empty):m_Type(t)
{
}

void SetType(CPlayer::ee_types t)
{
if(m_Type!=e_null_)
{
m_Type=t;
}
}

void SetNull_(){SetType(e_null_);}
void SetEmpty(){SetType(e_empty);}
void SetHuman(){SetType(e_human);}
void SetComp_(){SetType(e_comp_);}
void SetNet__(){SetType(e_net__);}
bool IsNull_()const{return m_Type==e_null_;}
bool IsEmpty()const{return m_Type==e_empty;}
bool IsHuman()const{return m_Type==e_human;}
bool IsComp_()const{return m_Type==e_comp_;}
bool IsNet__()const{return m_Type==e_net__;}
};

Игрок может иметь один из четырёх типов:
  • e_null_ — пустой тип. Если установить тип клетки e_null_, то сменить тип на другой уже нельзя.
  • e_empty — тип не задан.
  • e_human — игрок — человек (причём играющий на этом же компьютере).
  • e_comp_ — программный соперник.
  • e_net__ — соперник играет через сеть.
Сейчас будет реализован только тип e_human, для остальных типов будут поставлены «заглушки». (Работу по сети я собираюсь рассмотреть в отдельной статье, после чего к этой статье будет дописана ещё одна часть — с сетевым режимом игры.)
Тип фишек игрока m_PlrCellType будет назначаться в классе CGameplay.

Геймплей (класс).

Класс CGameplay — менеджер всего игрового процесса, название «геймплей» ему очень подходит (само слово «gameplay» непереводимо с английского на литературный русский и так же расплывчато по смыслу, хотя общая идея его — играбельность. Хм, объяснил, называется ))) ). Есть действия и свойства в игре, которые можно было бы реализовать как в одном классе, так и в другом, — и везде это выглядело бы логично. Поэтому, чтобы избегать неоднозначностей и споров, мы всё это поместим в неоднозначный же класс. Например, хранение массива игроков, пусть и состоящего всего из 2 элементов, или отслеживание очерёдности ходов и слежение за игровыми ситуациями. Класс CGameplay помогает сгруппировать логику работы игры для более, чем одного участника (а у нас так и есть).

Код:
//геймплей
class CReversi::CGameplay
{
CPlayer m_Player_null;
CPlayer m_Players[2];

public:
//индексы игроков 1 и 2
enum ee_pl
{
e_pl1=0,
e_pl2=1,
};

private:

CPlayer& GetPlayer1(){return m_Players[e_pl1];};
CPlayer& GetPlayer2(){return m_Players[e_pl2];};
CPlayer& GetPlayerByIndex(ee_pl indx)
{
if(indx!=e_pl1 && indx!=e_pl2)
{
return m_Player_null;
}
else
{
return m_Players[indx];
}
};

public:

CGameplay()
:m_Player_null(CPlayer::e_null_)
{
GetPlayerByIndex(e_pl1).SetHuman();
GetPlayerByIndex(e_pl2).SetHuman();
}
};

Борьба с мерцанием при перерисовке и обрезка области графики.

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

Отрисовка доски.

Код метода отрисовки доски получился следующим:

Код:
void PaintBoard(CDC& dc,const CRect& BoardRect)
{
CPen* pOldPen=0;
CBrush* pOldBrush=0;

//перья и кисти здесь объявлены статическими по той причине,
//что они всё равно используются только здесь, а конструктор
//вызовется для них один раз, а не заново при каждой перерисовке
static CPen penBoardLines(PS_SOLID,e_wid_boardLines,e_color_boardLines);

static CBrush bruWhiteBone(RGB(255,255,255));
static CBrush bruBlackBone(RGB(0,0,0));
static CPen   penWhiteBone(PS_SOLID,1,RGB(0,0,0));
static CPen   penBlackBone(PS_SOLID,1,RGB(255,255,255));

//сохраняем указатели на старые элементы контекста
pOldPen =dc.SelectObject(&penBoardLines);
pOldBrush =dc.SelectObject(&bruWhiteBone);

CRect RectToOut;

CRect CellRect;

//фон доски
{
RectToOut=BoardRect;
RectToOut.right++;RectToOut.bottom++;
dc.FillSolidRect(&RectToOut,e_color_boardBackGround);
}

//линии доски

//вертикальные (рисуются по левым сторонам клеток)
for(int c=0; c<e_xmax; c++)
{
if(!st_GetCellRect(c,0,BoardRect,CellRect))break;
dc.MoveTo(CellRect.left,BoardRect.top);
dc.LineTo(CellRect.left,BoardRect.bottom+e_wid_boardLines);

if(c+1==e_xmax)
{
//завершающая линия справа от последней клетки
dc.MoveTo(CellRect.right+1,BoardRect.top);
dc.LineTo(CellRect.right+1,BoardRect.bottom+e_wid_boardLines);
}
}

//горизонтальные (рисуются по верхним сторонам клеток)
for(int c=0; c<e_ymax; c++)
{
if(!st_GetCellRect(0,c,BoardRect,CellRect))break;

dc.MoveTo(BoardRect.left,                   CellRect.top);
dc.LineTo(BoardRect.right+e_wid_boardLines, CellRect.top);

if(c+1==e_ymax)
{
//завершающая линия снизу под последней клеткой
dc.MoveTo(BoardRect.left,CellRect.bottom+1);
dc.LineTo(BoardRect.right+e_wid_boardLines,CellRect.bottom+1);
}
}

//вывод фишек

DWORD x,y;
for(x=0; x<e_xmax; x++)
{
for(y=0; y<e_ymax; y++)
{
CCell& currentcell=Cell(x,y);

if(currentcell.IsBlack())
{
dc.SelectObject(&bruBlackBone);
dc.SelectObject(&penBlackBone);
}
else
if(currentcell.IsWhite())
{
dc.SelectObject(&bruWhiteBone);
dc.SelectObject(&penWhiteBone);
}
else
{
continue;
}

if(st_GetCellRect(x,y,BoardRect,CellRect))
{
//не забываем, что левый и верхний край клетки
//заняты линией сетки, поэтому для центровки
//фишки нужно сместить эти края
CellRect.left+=e_wid_boardLines;
CellRect.top +=e_wid_boardLines;

//рисуем фишку
RectToOut=CellRect;
RectToOut.right++;RectToOut.bottom++;
dc.Ellipse(&RectToOut);
}
}
}

dc.SelectObject(pOldPen);
dc.SelectObject(pOldBrush);
}
};

Чтобы игровое поле не было пустым, ставим на него стартовые фишки:

Код:
CReversi::CReversi()
{
...
...

m_pBoard->SetToBeginState();
...
...
}

В коде несколько раз встречается конструкция вида:

Код:
RectToOut=RectNeed;
RectToOut.right++;RectToOut.bottom++;
dc.Ellipse(&RectToOut);

Мы делаем так потому, что функции CDC::Ellipse и CDC::FillSolidRect используют для вывода прямоугольник, уменьшенный на единичку справа и снизу. Поэтому, чтобы вывести прямоугольник RectNeed, предварительно делаем RectToOut с увеличенными на 1 сторонами right и bottom.
Запускаем программу и любуемся результатом:


Рисунок 6.

Да, кстати, с меню-то ещё ничего не сделали — но об этом ниже. А теперь, наконец-то, посмотрим на мерцание графики при частой перерисовке. Добавьте в класс CReversi обработчик сообщения WM_MOUSEMOVE и заполните его следующим:

Код:
void CReversi::OnMouseMove(UINT nFlags, CPoint point)
{
//объявление графики всего ЭУ невалидной,
//что вызовет перерисовку
Invalidate(0);

CMyControl::OnMouseMove(nFlags, point);
}

Теперь, если водить курсором мыши по ЭУ, увидим, как по нему бегут полосы, а сама картинка подёргивается. Очень некрасиво всё это смотрится.

Причины мерцания.

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

Например, частота обновления экрана 75 герц (1 раз в 133 мс).  А картинка при движении курсора теперь перерисовывается, к примеру, 1 раз в 400 мс (хорошая такая картинка, полсекунды рисуется )) ). Получается, что пока картинка рисуется, экран успеет показать 3 различные фазы:
  • верхняя треть рисунка + 2/3 фона снизу;
  • верхние 2/3 рисунка + 1/3 фона;
  • весь рисунок.
Но такого чёткого совпадения не бывает, фаза всегда смещается, поэтому в дополнение к миганию картинки мы видим ещё бегущие полосы — смещающуюся контрастную границу «рисунок-фон». Если (а так обычно и бывает) рисунок рисуется с частотой быстрее 75 герц, то он также моргает из-за расхождения фаз отрисовки и обновления экрана.

Двойной графический буфер (double buffer).

Единственный выход — это вывести всю картинку на экран полностью за один (или менее) такт обновления экрана. Этого можно добиться, рисуя не напрямую на экранный контекст, а на контекст, расположенный в ОЗУ, — метод двойного буфера (double buffer).
Процесс отрисовки разбивается на следующие шаги:
  • картинка рисуется в памяти столько времени, сколько для этого нужно;
  • затем быстро выводится на экран (например, с использованием bitblt);
  • в памяти рисуется новая картинка, а старая всё это время радует глаз своей неподвижностью;
  • новое изображение быстро выводится на экран;
  • и так далее.
Для глаза картинка меняется кадр за кадром, пусть и относительно медленно, но без полунарисованных скачков. Однако полная гладкость и синхронность возможны только при использовании двух буферов для кадра экрана и их синхронного с кадровой разверткой переключения, что в GDI нам не доступно.

Реализация двойного буфера.

Подкорректируем код метода OnPaint() класса CMyControl, а именно вот эту его часть

Код:
void CMyControl::OnPaint()
{

...
...
//прямоугольник комнаты
CRect r_Room;
GetRoomRect(r_CL,r_Room);
PaintRoom(dc,r_Room);
...
...
}

Что мы делаем.
Первое. Объявляем и создаём в памяти контекст устройства memDC, совместимый с контекстом устройства экрана dc. (Совместимый — то есть, совместимый по используемым цветам и разрядностью цвета)

Код:
void CMyControl::OnPaint()
{

...
...
{
//прямоугольник комнаты
CRect r_Room;
GetRoomRect(r_CL,r_Room);

//объявляем контекст для рисования
CDC memDC;
//создаём объект контекста, совместимый с dc
if(!memDC.CreateCompatibleDC(&dc))return;

...
...
}

...
...

Второе. Объявляем и создаём в памяти битмап (bitmap, грубо говоря, — массив пикселов), совместимый с контекстом dc и размером с прямоугольник «комнаты» (напомню, область графики под меня я назвал Room). Без присоединённого битмапа на контекст рисовать невозможно — просто напросто ничего не нарисуется. Когда будем присоединять битмап к контексту memDC (действие называется «выбрать объект битмапа в контекст»), не забываем сохранить указатель на старый битмап, ведь его потом надо будет вернуть на место.

Код:
...
...
//хранит указатель на предыдущий битмап контекста memDC
CBitmap* oldBmp;

//объявляем битмап
CBitmap memBmp;

//создаём объект битмапа, совместимого с memDC и с
//размермами как у r_Room
if(!memBmp.CreateCompatibleBitmap(&dc,r_Room.Width(),r_Room.Height()))return;

//выбираем битмап в контекст и сохраняем указатель на старый битмап
oldBmp=memDC.SelectObject(&memBmp);
...
...

В следующих шагах обязательно учитываем тот факт, что размер битмапа меньше, чем размер всего ЭУ, ведь прямоугольник r_Room — это всего лишь часть от прямоугольника клиентской области ЭУ — r_CL. Если бы мы создали битмап размером с r_CL, вопрос сам собой бы отпал. Но коль скоро мы создали его размером с r_Room, необходимо рисовать на прямоугольнике r_Room, сдвинутом левым верхним углом в начало координат.
Рисуем на контексте memDC:

Код:
...
...
//прямоугольник для рисования, сдвинутый в начало координат
CRect rectWhereToPaintTo=r_Room-r_Room.TopLeft();

//виртуальный метод отрисовки комнаты
PaintRoom(memDC,rectWhereToPaintTo);

//копируем нарисованное с контекста memDC на контекст экрана dc
dc.BitBlt(r_Room.left,r_Room.top,r_Room.Width(),r_Room.Height(),&memDC,0,0,SRCCOPY);
...
...


Рисунок 7.

Теперь, как видите, графика не выйдет за пределы прямоугольника комнаты — это в принципе невозможно, так как за пределами нет битмапа :) . Ошибки во время рисования за пределами битмапа не возникнет, просто «заграничные» пикселы нигде не сохранятся.
Ну, и не забываем вернуть старый битмап на место. После этого битмап в памяти можно разрушить (DestroyObject()), хотя и так это само произойдёт в деструкторе, когда объект класса CBitmap выйдет из области видимости. Вот если вы захотите создать в этом же объекте другой битмап, то вызвать сначала DestroyObject() придётся точно.

Код:
...
...
//возвращаем старый битмап на место
memDC.SelectObject(oldBmp);

//рушим объекты в памяти
//(хотя этого можно не делать — всё равно произошло бы в деструкторах)
//но для порядка я это показал тут
memBmp.DeleteObject();
memDC.DeleteDC();
}
...
...

Теперь движение курсора мыши по поверхности ЭУ не вызывает мерцания, хотя перерисовка вызывается всё так же часто. Любопытный эффект сможете наблюдать, если в строке создания битмапа передать указатель не на dc, а на memDC.

Код:
if(!memBmp.CreateCompatibleBitmap(&memDC,r_Room.Width()+1,r_Room.Height()+

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


Рисунок 8.

А теперь сюрприз... Растяните главное окно за край — мерцает? :) Причина этого мерцания кроется в методе CMyGraphControlDlg::ResizeControl(). Можете убедиться в этом — заремить код метода и попробовать снова. Собака зарыта в строчках.

Код:
//перерисовка всего диалога со стиранием
Invalidate(1);

Вся графика диалога объявляется невалидной, причём указывается, что содержимое надо перед перерисовкой очистить. Вот и появляется мигание. Если указать 0 (не стирать предыдущее содержимое), то проблема почти решается. Почти — потому что при изменении размера диалога будет оставаться мусор от размазанного старого содержимого вне нашего ЭУ.


Рисунок 9.

Выход есть. Нужно после инвалидации графики всего диалога сказать, что прямоугольник сЭУ трогать не нужно, а самому ЭУ сообщить, что пора перерисоваться. Допишем код:

Код:
void CMyGraphControlDlg::ResizeControl()
{
...
...
if(currRect!=newRect)
{
//задаём новый размер и положение
m_IDC_MY1.MoveWindow(&contRect,0);

//перерисовка всего диалога со стиранием
Invalidate(1);

//contRect — относительные координаты окна ЭУ
m_IDC_MY1.GetClientRect(&contRect);

// contRect+=CPoint(50,50);//дополнительная демонстрация

ValidateRect(&contRect);
m_IDC_MY1.Invalidate(0);//0 — стирать не надо
}
}

Теперь всё, как надо.
Раскройте строчку «дополнительная демонстрация», она сдвигает прямоугольник ЭУ, так что будет виден мерцающий край — визуальное подтверждение нашей победы. А то вдруг ЭУ сам по себе перестал моргать ? :)
Ну и не забудьте удалить строчку с демонстрацией.

Красота требует жертв.

Проведём сравнение производительности.

Код:
...
...
//прямоугольник комнаты
CRect r_Room;
GetRoomRect(r_CL,r_Room);

DWORD dwd1=::GetTickCount();
for(int i=0; i<1000; i++)
#if(0)//переключатель теста
{
PaintRoom(dc,r_Room);
}
#else
{
{
CDC memDC;
if(!memDC.CreateCompatibleDC(&dc))return;
CBitmap* oldBmp;
CBitmap memBmp;
if(!memBmp.CreateCompatibleBitmap(&dc,r_Room.Width(),r_Room.Height()))
return;
oldBmp=memDC.SelectObject(&memBmp);
CRect rectWhereToPaintTo=r_Room-r_Room.TopLeft();

PaintRoom(memDC,rectWhereToPaintTo);
dc.BitBlt(r_Room.left,r_Room.top,r_Room.Width(),r_Room.Height(),
&memDC,0,0,SRCCOPY);
memDC.SelectObject(oldBmp);
memBmp.DeleteObject();
memDC.DeleteDC();
}
}
#endif
DWORD dwd2=::GetTickCount();
DWORD dwdDiff=dwd2-dwd1;
CString txt;
txt.Format("отрисовано примерно за %u мс",dwdDiff);
GetParent()->SetWindowText(txt);
...
...

Двигаем мышью. Результат для теста без двойного буфера: 516...540 мс, а с двойным — 593...625 мс. Воистину, красота требует жертв. :) Впрочем, небольших, как видим.
Да, не забудьте убрать Invalidate(0) из обработчика CReversi::OnMouseMove.

Меню игры.

Собственно, меню нуждается в доработке и переработке.
Убираем мерцание меню.

Сперва добавим код избавления от мерцания и выхода графики за край. Вы уже знаете, как это делается, поэтому не описываю.

Код:
void CMyControl::PaintMenu(CDC& dc,const CRect& ClRect,const CRect& MenuRect,bool bUseCalmForAll)
{
{
//прямоугольник меню

CRect RectToOut;
RectToOut=MenuRect;
/*RectToOut.right++;*/RectToOut.bottom++;

CDC memDC;
if(!memDC.CreateCompatibleDC(&dc))return;

//хранит указатель на предыдущий битмап контекста memDC
CBitmap* oldBmp;
CBitmap memBmp;

//создаём объект битмапа, совместимого с memDC и с
//размермами как у RectToOut
if(!memBmp.CreateCompatibleBitmap(&dc,RectToOut.Width(),RectToOut.Height()))return;
oldBmp=memDC.SelectObject(&memBmp);

//прямоугольник для рисования, сдвинутый в начало координат
CRect RectToOut_0=RectToOut-RectToOut.TopLeft();
CRect ClRect_0=ClRect-ClRect.TopLeft();

//------------------

//выбираем шрифт для текста кнопок
CFont* oldFont=memDC.SelectObject(&m_MenuFont);

memDC.FillSolidRect(&RectToOut_0,::GetSysColor(COLOR_3DFACE));

memDC.FillSolidRect(MenuRect.left,MenuRect.bottom,MenuRect.Width(),1,
::GetSysColor(COLOR_3DSHADOW));

ee_button_state st=(bUseCalmForAll?e_but_calm:e_but_auto);

it_v_buttons it=m_v_buttons.begin();
for(DWORD dwd=0; it!=m_v_buttons.end(); dwd++,it++)
{
PaintButton(memDC,ClRect_0,dwd,st);
}

//возвращаем старый шрифт в контекст
dc.SelectObject(oldFont);

//------------------

//копируем нарисованное с контекста memDC на контекст экрана dc
dc.BitBlt(RectToOut.left,RectToOut.top,RectToOut.Width(),RectToOut.Height(),&memDC,0,0,SRCCOPY);

//возвращаем старый битмап на место
memDC.SelectObject(oldBmp);
}
}

Придание гибкости работы с меню.

Теперь необходимо добавить в класс CMyControl методы работы с меню — добавление пунктов, удаление, изменение. Выпадающее меню делать не будем (по крайней мере, не сейчас). Самое первое: заменяем  константы enum ee_buttons на интерфейс добавления команд. Попутно добавляем виртуальную функцию AfterCreate() — пригодится при заполнении меню из класса CReversi.

CMyControl.h:
Код:
class CMyControl
{
...
...
/*
enum ee_buttons
{
e_but_noname0,
e_but_noname1,
e_but_noname2,
//
e_but_buttons_count,//количество кнопок
};
*/

public:
//ID могут повторяться
bool Menu_AddCommandToEnd(const int ID,const char* pText);

virtual void AfterCreate(){};

...
...
};

CMyControl.cpp:
Код:
bool CMyControl::CreateMe(CWnd* pParent,UINT ID,CRect* pRect)
{
...
...
bool bResult=(m_hWnd!=0 && m_bIsCreated);

if(bResult)
{
AfterCreate();
}

return bResult;
}

bool CMyControl::Menu_AddCommandToEnd(const int ID,const char* pText)
{
//ограничим до 20 команд
if(m_v_buttons.size()>=20)return false;

s_button_handle bh;
bh.Set(ID,pText);
m_v_buttons.insert(m_v_buttons.end(),bh);
Invalidate(1);
return true;
}

CReversi.h:
Код:
class CReversi:public CMyControl
{
...
...
//идентификаторы кнопок игры
enum
{
e_butID_newGame_,//кнопка начала новой игры
e_butID_exitGame,//кнопка выхода
//----------
e_butID_count,//количество кнопок
};
...
...

public:
virtual void AfterCreate();

...
...
};

CReversi.cpp:
Код:
void CReversi::AfterCreate()
{
//добавляем в меню команды
Menu_AddCommandToEnd(e_butID_newGame_,"Новая игра" );
Menu_AddCommandToEnd(e_butID_About___,"О программе" );
Menu_AddCommandToEnd(e_butID_exitGame,"Выход" );
};

Обработка сообщений от меню.

Обработать сообщения можно множеством способов. Вот некоторые из них:
  • Отправить окну сообщение WM_COMMAND с нужными параметрами. Нам это не подходит, поскольку мы не позаботились о том, что ID кнопок меню не совпадут с ID других дочерних окон, если они вдруг у нас появятся для каких-либо целей. Да и зачем слать лишние сообщения, когда можно напрямую вызовы делать?
  • Функции обратного вызова — работают быстрее и понятнее, чем сообщения, но с ними тоже возни немало — для каждого сообщения определить функцию да привязать указатель на функцию к кнопке. В общем, самое для нас удобное сейчас, это.
  • Функция CMyControl::ProcessMenuCommand(), которая уже присутствует в нашем коде.
Внесём изменения: передадим в функцию индекс кнопки и структуру описания кнопки, а также завиртуалим функцию, чтобы классы потомки имели возможность ею воспользоваться для обработки сообщений от меню

Код:
#include <vector>

class CMyControl
{
...
...
public:
//описание кнопки передаём по значению!
virtual void ProcessMenuCommand(int Index,const s_button_handle Button);

...
...
};

void CMyControl::ProcessMenuCommand(int Index,const s_button_handle Button)
{
//здесь ничего не делается
}

В ходе этих изменений пришлось в нескольких местах подправить пару штрихов, описывать не буду — компилятор сам подскажет, что где сделать. Главное, что надо соблюсти — передавать описание кнопки s_button_handle Button по значению, а не по указателю, так как меню может измениться (добавим или удалим что-нибудь), тогда указатель окажется невалидным.
И, наконец, переопределим функцию в CReversi.

CReversi.cpp:
Код:
#include "stdafx.h"

#include "MyGraphControl.h"//только ради  видимости theApp

#include "CReversi.h"

...
...

void CReversi::ProcessMenuCommand(int Index,const s_button_handle Button)
{
switch(Button.GetButtonID())
{
case e_butID_exitGame:
{
extern CMyGraphControlApp theApp;
theApp.m_pMainWnd->PostMessage(WM_QUIT,0,0);
}
break;

case e_butID_newGame_:
{
//...
}
break;

case e_butID_About___:
{
//...
}
break;
}
}

Один пункт меню — «Выход» — у нас теперь реально работает. В обработчике кнопки в очередь сообщений главного окна посылается сообщение WM_QUIT, что приводит к выходу из программы.

Заключение к части №3.

Часть №3 получается длинноватой, поэтому передохнём. В следующей части мы допишем класс CGamePlay (и другие классы тоже), сделаем режим для двух игроков-людей за одним компьютером и режим игры по сети. Попутно освоим работу с битмапами-ресурсами.
Для реализации сетевого режима необходима для логической связности ещё одна отдельная статья, где я предложу вашему вниманию класс-менеджер клиент-серверного приложения. Постараюсь часть №4 и «сетевую» статью подготовить одновременно к следующему выпуску.


(Ожидается продолжение.)
Версия для печати
Обсудить на форуме