每天一个编程小项目,提升你的编程能力!
游戏介绍
下围棋的程序,实现了界面切换,选择路数,和围棋规则,也实现了点目功能,不过只有当所有死子都被提走才能点目,不然不准确
操作方法
鼠标操作
游戏截图
编译环境
VisualStudio2019,EasyX_20211109
文件描述
用广度寻路寻找周围所有相同棋子,直到四处碰壁了,得到包围住自己的所有点,看看这些点是空地的数量,空地的数量就是气的数量,气为 0 这些子全部提掉,设为空地。每下一步棋记录下这步棋的位置,悔棋时把这些点提掉。打劫时在存悔棋的点的地方找到劫争的地方,只吃了一颗子并且落在劫争的地方就不能下。
点目就是找到一块空地,看看围住它的是不是都是同一个颜色的子,是的话这块空地有多大这个颜色的子的目数就加多大。
如果围住空地的是不同颜色的子,那么这块空地多大,这两个子的气就加这块空地的目数的一半。
其他功能就是设计模式的问题了,我借鉴了 cocos2d 的设计模式,在 director 里运行整个程序,在 scene 里写这个层运行的功能,director 的 runwithscene 写切换场景。
详解:
1、公共工具
MyTool
这个文件里包含了广度寻路和围棋地图的类,其中围棋地图通过广度寻路实现了吃子,提子,点目的功能。还有一些之前做七巧板的项目时保存下来的类,暂且用不到,但是也许能方便未来开发,所以放到一起。
DealArray处理数组的头文件,目前有三个函数,作用分别是:将 vector 首尾颠倒、判断一个元素是否在 vector 里面,判断两个 vector 是否相等(每个元素都相等就是两个 vector 相等),函数实现为
// 这个实现 vector 首尾颠倒
template
void Reserve_Vector(vector
{
for (int i = 0;i < (arr.size() >> 1);i++)
{
Vector_Reverse temp = arr[i];
arr[i] = arr[arr.size() - i-1];
arr[arr.size() - i - 1] = temp;
}
}
// 这个实现判断一个元素是否在 vector 里面
template
bool ifNotInVector(vector
{
for (VectorInclude i : arr)
{
if (i == num)return false;
}
return true;
}
// 这个实现判断两个 vector 是否相等
template
bool ifTwoVectorEqual(vector
{
if (arr.size() != ano.size())return false;
for (int i = 0;i < arr.size();i++)
{
if (arr[i] != ano[i])return false;
}
return true;
}
MapPoint地图点的类,由 indexX 存放列数,indexY 存放行数,有 PathDir 枚举类型枚举四个方向,能通过 MapPoint getDirPoint(PathDir turn) 这个函数获得四个方向的点,这个函数长这样
MapPoint getDirPoint(PathDir turn)
{
switch (turn)
{
case path_up:
return MapPoint(this->indexX,this->indexY-1);
break;
case path_down:
return MapPoint(this->indexX, this->indexY + 1);
break;
case path_left:
return MapPoint(this->indexX-1, this->indexY);
break;
case path_right:
return MapPoint(this->indexX+1, this->indexY);
break;
default:
break;
}
}
同时这个类也用于保存 BoundingBox 类的坐标,因为 easyx 里的每个点都是整型,所以保存的坐标也是整型。
PathNode广度寻路的节点类,也就是树的数据结构,一个父节点,多个子节点由
MapPoint pos;
PathNode* parent;
vector
这三个数据组成,pos 是这个节点所在的位置,parent 是这个节点的父节点,child 是这个节点的子节点们
为方便清理 PathNode 节点,这个类里还提供了静态函数
static void clearNode(PathNode* p_head)
{
if (p_head->child.empty())
{
if (p_head->parent != nullptr)
{
PathNode* temp = p_head->parent;
for (int i = 0;i < temp->child.size();i++)
{
if (temp->child[i] == p_head)
{
temp->child.erase(temp->child.begin() + i);
break;
}
}
}
delete p_head;
}
else
{
// vector 的遍历不够好,直接这么清除子节点
while (!p_head->child.empty())
{
clearNode(p_head->child[0]);
}
}
}
要清理掉一整个 PathNode* phead 树只需
PathNode::clearNode(phead);
BFS广度寻路的类,包含
function
unsigned int size_Width, size_Height;// 地图尺寸,width 为宽度,height 为高度
bool* AuxiliaryMaps;// 辅助地图,判断某个点是否走过
四个数据,用
void BFS::setMap(unsigned int size_Width, unsigned int size_Height, function
{
this->size_Height = size_Height;
this->size_Width = size_Width;
ifCanThrough = CallBack;
}
设置地图尺寸和判断是否可以通行的函数指针,setMap 用法如下
int map[3][3] =
{
0,1,0,
0,1,0,
0,0,0
};
BFS bfs;
bfs.setMap(3, 3, [&](MapPoint num)
{
if (map[num.indexY][num.indexX] == 0)return true;
return false;
});// 这是用 lambda 表达式写的
// 也可以用函数指针如
bool ifCanThrough(MapPoint num)
{
if (map[num.indexY][num.indexX] == 0)return true;
return false;
}
bfs.setMap(3, 3, ifCanThrough);
// 或者
bool (*p)(MapPoint)=ifCanThrough;
bfs.setMap(3,3,p);
初始化 AuxiliaryMap 用
void initAuxiliaryMaps()
{
AuxiliaryMaps = new bool[size_Height * size_Width];
memset(AuxiliaryMaps, false, sizeof(bool)*size_Height*size_Width);
}
清理 AuxiliaryMap 用
void clearAuxiliaryMaps()
{
if (AuxiliaryMaps != nullptr)delete AuxiliaryMaps;
AuxiliaryMaps = nullptr;
}
AuxiliaryMap(辅助地图)的作用是每次遍历一个广度寻路的节点就把该节点的位置的 bool 值设为 true 表示这个点寻找过了,避免重复寻找同一个位置,寻路完就把辅助地图清理掉。
由于不知道 ifCanThrough 是否判断点是否在地图内,所以要多写一个判断点是否在地图内的函数,避免访问 AuxiliaryMap 时数组越界,这个函数为
bool ifInMap(MapPoint num)
{
if (num.indexX >= 0 && num.indexX < size_Width && num.indexY >= 0 && num.indexY return false; } 现在辅助地图有了,广度寻路的节点有了,是否可以通行的判断也有了,可以根据广度寻路的算法用起点和终点的值找到可以通行的路径了,寻找路径的函数为 vector 函数过长,就不贴出来了,广度寻路的步骤是 1、将起点放进 PathNode* phead 2、将 phead->pos 在 AuxiliaryMap 对应的点的 bool 设为 true,即 AuxiliaryMap[phead->pos.indexY*size_Width+phead->pos.indexX]=true; 3、判断 phead->pos 上下左右四个方向的点是否找寻过,是否可以通行,未找寻过可以通行则把这个点放入 phead 的子节点,phead->addchild(new PathNode(MapPoint(phead->pos.getDirPoint(path_up /* 或者 path_down path_left path_rght */)))); 并且放进 vector 4、遍历 child,看看有没有点到达终点,没有进入步骤 5,有进入步骤 8 5、令 vector 6、如果 child 为空,进入步骤 7,如果 child 不为空,进入步骤 4 7、返回空的 vector 8、把找到的 PathNode 节点保存下来,不停找 pathNode 的父节点,把每个父节点的 pos 值 push_back 进 vector 具体函数实现看 BFS 里的 vector 实现这个功能其实对围棋这个项目没有帮助,但是都封装出了这个类,不实现一下这个功能总归有点缺憾,围棋要判断所有能走的点,只需要在广度寻路的八个步骤中去掉对是否到达终点的判断就行了,得到包围这块区域的点只需要在寻找所有能走的点时遇到 ifCanThrough 为 false 的点时把该点所在 AuxiliaryMap 的 bool 值设为 true 并存进 vector vector vector BFS 中还实现了单步寻路的功能 vector 这个的用法是 int map[3][3] = { 0,1,0, 0,1,0, 0,0,0 }; bool ifCanThrough(MapPoint num) { if (map[num.indexY][num.indexX] == 0)return true; return false; } BFS bfs; PathNode* begin = new PathNode(MapPoint(0, 0)); bfs.setMap(3, 3, ifCanThrough); bfs.initAuxiliaryMaps(); vector while(!reslt.empty()) { // .....这里写每步寻路后的操作 reslt = bfs.SingleSearch(begin); } bfs.clearAuxiliaryMaps(); PathNode::clearNode(begin); MapNode地图节点,我试图用图的数据结构来写围棋的地图,这样地图上的每个点都是指针,加上 Map 是个单例模式,得到的每个点,点每个点的处理都会反应到真实的地图上,不用重复传参。 这个头文件有 Piece 枚举类型 enum Piece {Black,White,Space}; 表示围棋的黑子,白子,空地三种类型 这个类有 // 上下左右四个节点 MapNode* Node_Left; MapNode* Node_Right; MapNode* Node_Up; MapNode* Node_Down; // 这个点的棋子 Piece Node_Piece; // 这个点原来的棋子 Piece original_Piece; // 这个点的坐标 int indexX, indexY; // 这个点棋子被改变的次数 unsigned int changeTimes; 9 个数据 要清理整个地图调用 // 在图中的任何一个点都可以用于清除整个图 void MapNode::DeleteChild() { // 从父节点到子节点疯狂扩散来清理子节点 vector vector if (this->Node_Down) { child.push_back(this->Node_Down); this->Node_Down->Node_Up = nullptr; this->Node_Down = nullptr; } if (this->Node_Up) { child.push_back(this->Node_Up); this->Node_Up->Node_Down = nullptr; this->Node_Up = nullptr; } if (this->Node_Left) { child.push_back(this->Node_Left); this->Node_Left->Node_Right = nullptr; this->Node_Left = nullptr; } if (this->Node_Right) { child.push_back(this->Node_Right); this->Node_Right->Node_Left = nullptr; this->Node_Right = nullptr; } while (!child.empty()) { parent = child; child.clear(); for (MapNode* parent_Node : parent) { if (parent_Node->Node_Down) { if(ifNotInVector(child, parent_Node->Node_Down)) child.push_back(parent_Node->Node_Down); parent_Node->Node_Down->Node_Up = nullptr; parent_Node->Node_Down = nullptr; } if (parent_Node->Node_Up) { if(ifNotInVector(child, parent_Node->Node_Up)) child.push_back(parent_Node->Node_Up); parent_Node->Node_Up->Node_Down = nullptr; parent_Node->Node_Up = nullptr; } if (parent_Node->Node_Left) { if(ifNotInVector(child, parent_Node->Node_Left)) child.push_back(parent_Node->Node_Left); parent_Node->Node_Left->Node_Right = nullptr; parent_Node->Node_Left = nullptr; } if (parent_Node->Node_Right) { if(ifNotInVector(child, parent_Node->Node_Right)) child.push_back(parent_Node->Node_Right); parent_Node->Node_Right->Node_Left = nullptr; parent_Node->Node_Right = nullptr; } delete parent_Node; } } } 这个函数。这个函数不会把自己清理掉,只会把自己周围的所有节点设为 nullptr,所以可以放心在析构函数里用它。 悔棋时把这个点设为某个棋子用 void MapNode::UndoSetPiece(Piece num) { changeTimes--; if (changeTimes == 1)original_Piece = Space; else if (num == original_Piece) { switch (num) { case White:original_Piece = Black;break; case Black:original_Piece = White;break; default: break; } } Node_Piece =num; } 悔棋时这个点如果棋子改变次数大于 2,设为与原先相同的子时原先的子就要设为的这个子的相反面,这点有一点小逻辑在里面,当然如果改变次数为 2,要设为任何子,原来的子都会是空地。有闲心的可以自己推一下。 不悔了和落子时把这个点设为某个棋子时用 void MapNode::setPiece(Piece num) { if(num==Space&&Node_Piece!=Space)original_Piece = Node_Piece; Node_Piece = num; changeTimes++; } StepPoint每一步的点,用于存每一步落子的地方和每一步悔棋的地方,还有每一步劫争的 MapNode,用于实现悔棋和不悔了的功能,共有 int indexX, indexY; // 下的位置 bool ifUpBeEated; // 上边有没有被吃 bool ifDownBeEated; // 下边有没有被吃 bool ifLeftBeEated; // 左边有没有被吃 bool ifRightBeEated; // 右边有没有被吃 Piece Step_Piece; // 这一步是什么棋子 MapNode* kozai; // 这一步劫争的地方 八个数据,如果上边有被吃,就把上边的所有空地找到,设为与这一步棋子相反的棋子,下,左,右亦然,四个方向判断完后再把这颗子提掉,这就是悔棋的逻辑,不用存下被吃掉的所有点,用四个 bool 值就省去了很多内存。 Map,地图的所有数据及数据的处理都在 Map 这个类里。 这是个单例模式的类,单例模式就是任何人不能 new 出一个对象,只有这个类自己才能给出自己的模样,具体写法为 class A { public: ~A() {}// 析构函数一定要是公有的 static A* getInstance()// getInstance 一定要是静态的 { if (p_Ins == nullptr)p_Ins = new A; return p_Ins; } private: A() {};// 构造函数一定要是私有的 static A* p_Ins;// 这个不能在构造函数里初始化 }; A* A::p_Ins = nullptr;// 这个不能漏 具体用法你得多多实践才能理解透彻,例如写一个回合制对战游戏,一个英雄一个怪物,一回合轮一个人发动攻击或者防御什么的,调整每个人的攻击力,防御力,暴击率,看看最后是谁赢了这个小项目,你用单例模式试着做一下差不多就能理解了。之后要说的模拟 cocos 就用到了一个单例模式,也是至关重要的单例模式。 Map 共有 MapNode* Entity;// 实体 int sizeX, sizeY; stack stack function 这六个数据,且这六个数据都是私有的 drawPiece 是个函数指针,由于地图的不同,drawPiece 函数也会不同,所以具体情况具体赋值,这个 drawPiece 相当于一个虚函数。 为 drawPiece 赋值的接口为 void setDrawPiece(function { drawPiece = num; } Entity 是地图数据的实体,通过不断地访问 MapNode* Node_Left; MapNode* Node_Right; MapNode* Node_Up; MapNode* Node_Down; 这四个节点来到达地图上的任何一个地方。具体函数为 MapNode* Map::getMapNode(int indexX, int indexY) { if (!ifInMap(indexX, indexY))return nullptr; MapNode* result=Entity; for (int xx = 0;xx < indexX;xx++)result = result->Node_Right; for (int yy = 0;yy < indexY;yy++)result = result->Node_Down; return result; } sizeX,sizeY 是地图尺寸,用于广度寻路。 everyStep 储存每一步子落在的地方,everyUndoStep 储存每一步悔棋提掉的子所在的地方,都是 stack 结构来存的。 一开始棋盘是空的,所以通过 void Map::setBlankMap(int width, int height) { sizeX = width; sizeY = height; if (Entity != nullptr) { Entity->DeleteChild(); delete Entity; Entity = nullptr; } Entity = new MapNode; Entity->indexX = 0; Entity->indexY = 0; MapNode* currentY = Entity; MapNode* currentX = Entity; for (int indexY = 0;indexY < height;indexY++) { currentX = currentY; if (indexY != height - 1) { currentY->Node_Down = new MapNode; currentY->Node_Down->Node_Up = currentY; currentY = currentY->Node_Down; currentY->indexX = 0; currentY->indexY = indexY + 1; } for (int indexX = 0;indexX < width-1;indexX++) { currentX->Node_Right = new MapNode; currentX->Node_Right->Node_Left = currentX; if (currentX->Node_Up && currentX->Node_Up->Node_Right) { currentX->Node_Right->Node_Up = currentX->Node_Up->Node_Right; currentX->Node_Up->Node_Right->Node_Down = currentX->Node_Right; } currentX = currentX->Node_Right; currentX->indexX = indexX + 1; currentX->indexY = indexY; } } while (!everyStep.empty())everyStep.pop(); while (!everyUndoStep.empty())everyUndoStep.pop(); } 来初始化 Entity,sizeX,sizeY。 围棋的流程为一个人下一颗子,判断这颗子吃了几颗子,把吃掉的子提掉,判断能不能下在这里(提掉的子大于一或提掉的子为一且不在 everyStep.top().kozai 的地方,没有提掉的子且自身的气不为 0),能下在这里就下在这里,不能下在这里就重新下,下完轮到另一个人。吃掉子,判断在不在劫争的位置,判断自身的气是否为 0 都要判断气,所以首先要实现判断一个区域的气的功能。 在 Map 里判断一个区域气的功能我写为两个函数 vector { vector MapNode* num = getMapNode(indexX, indexY); BFS calc; calc.setMap(sizeX, sizeY, [&](MapPoint val) { if (getMapNode(val.indexX, val.indexY)->Node_Piece != num->Node_Piece)return false; return true; }); vector for (MapPoint i : enclose_point) { result.push_back(getMapNode(i.indexX, i.indexY)); } return result; } int Map::getZoneQi(int indexX, int indexY) { int result = 0; vector for (MapNode* i : enclose_point) { if (i->Node_Piece == Space)result++; } return result; } getZoneQi 就是判断一个区域气的函数。 判断一个区域的气为 0,那就要把这块区域设为空地,这个需要得到这块区域所有的点,然后把这块区域所有点设为空地,实现这个功能需要两个函数 vector { vector MapNode* num = getMapNode(indexX, indexY); BFS calc; calc.setMap(sizeX, sizeY, [&](MapPoint val) { if (getMapNode(val.indexX, val.indexY)->Node_Piece != num->Node_Piece)return false; return true; }); vector for (MapPoint i : next_point) { result.push_back(getMapNode(i.indexX, i.indexY)); } return result; } void Map::setZoneSpace(int indexX, int indexY) { vector for (MapNode* i : next_point) { i->setPiece(Space); } } 能吃子,能提子,然后才能落子,落子的功能比较复杂,函数也比较长,总的来说就是 bool putOnePiece(int indexX, int indexY,Piece num); 这个函数,如果这个点能落子返回 true,不能落子返回 false。具体实现看 gitee 上的源码 悔棋功能写在 bool Undo(); 不悔了的功能写在 bool UnUnDo(); 之所以有返回值是因为有可能没落子就有人按悔棋,或者没悔过棋就有人按不悔了,返回的 bool 值是悔棋和不悔了是否成功。 代码没什么好说的,看源码就是了,有点长。 点目功能写在 double getMesh(Piece num); 里,有点长,看源码去。 至此围棋这个游戏的逻辑已经全部实现了,接着就是界面的切换 2、SimulationCocos(模拟 Cocos) 模拟 Cocos 有三个模块,Menu,Scene,Director Menu 菜单,用于保存每个按钮的类,每个场景里只有一个菜单,菜单里有 MenuItem (菜单项) MenuItem菜单项,是一个双向链表,每个菜单里只有一个 MenuItem 链表,每个 MenuItem 里包含一个 Button Button包含三个函数指针 function function function 和一个 BoundingBox 类。 BoundingBox边框,包含 int size_width, size_height; // 尺寸 MapPoint Place; // 左上角位置 三个数据,判断某个点是否在 BoundingBox 里面调用 bool BoundingBox::ifInBoundingBox(MapPoint num) { int heightest, lowest, leftest, rightest; heightest = Place.indexY; lowest = Place.indexY + size_height; leftest = Place.indexX; rightest = Place.indexX + size_width; if (num.indexX >= leftest && num.indexX <= rightest && num.indexY >= heightest && num.indexY <= lowest)return true; return false; } 当一个场景里发生了点击反应,只需在场景的 Menu 里调用 MenuItem* Menu::IfHappendEvent(int xx, int yy) { MenuItem* current = head; bool ifFind = false; while (current != nullptr) { if (current->ifThisIsCalling(xx, yy)) { ifFind = true; break; } current = current->child; } if(ifFind) return current; return nullptr; } 就能判断是否按到了某个按钮以及得到那个按钮的 MenuItem 值,然后调用 MenuItem 的按钮的 ResponseFunc 当点击反应结束时调用响应中的按钮的 Restore 然后判断鼠标所在的位置还在不在按钮里面,在的话调用按钮的 Call_Back 函数,函数里面传的参是按钮的边框,用于绘制按钮。 Scene场景,继承自 GameNode 类, GameNode是一个双向链表,有 virtual bool initMySelf() { return true; } virtual bool operation() { return true; } virtual void EndOperation(){} 三个虚函数,operation 是场景运行时的函数,EndOperation 是令场景结束运行的函数,initMySelf 是初始化场景的函数 同时还有 bool GameNode::ifInRace(GameNode* num) { GameNode* current = this; while (current!=nullptr) { if (current == num)return true; current = current->child; } current = this; while (current!=nullptr) { if (current == num)return true; current = current->parent; } return false; } 判断某个场景是否和自己有血缘关系。有血缘关系返回 true,无血缘关系返回 false 在 Scene 里有 function 这个函数指针,也算是个虚函数,交由子类实现,子类必须实现这个函数指针,不然一定会报错,所以也可以称作不会报错的纯虚函数吧。 还有 bool ifExit; 是否退出场景的判断 在 Scene 里实现了 bool Scene::operation() { ifExit = false; while (true) { Operat_Func(); if (ifExit)break; } return true; } void EndOperation() { ifExit = true; }; 这两个函数,operation 里面真正的精华是 Operat_Func(); 这个函数,这个函数交由 Scene 的子类实现。Scene 的子类可以通过调用 this->EndOperation(); 这个函数退出场景。 Director,单例模式,程序运行的核心,每个 Scene 都在 Director 里运行。只有两个数据 bool ifExit; // 是否退出的判断 GameNode* IsRunning; // 当前运行的场景 Director 里主要通过两个函数来实现 Scene 的运行和场景的切换 void Director::RunWithScene(GameNode* scene) { if (IsRunning != nullptr) { IsRunning->EndOperation(); } IsRunning = scene; } void Director::Operation() { ifExit = false; GameNode* temp = IsRunning; while (true) { if (temp == nullptr)break; temp->initMySelf(); if (temp->operation())// 场景一律在这个判断里运行,退出场景时进入判断 { if(!IsRunning->ifInRace(temp))// 此时 IsRunning 已经通过 Director::getInstance()->RunWithScene(...); 改变了自己 delete temp; temp = IsRunning; } if (ifExit)break; } } IsRunning 变了,temp 不变,原来的场景能运行至结束然后才跳出,释放掉原来场景的内存接着才运行新的场景,这就是 Director 的核心逻辑,Director 需要和 Scene 互相引用,Scene 通过访问 Director 类直接访问当前正在运行的程序,如果 Director 不是单例模式,那么 Scene 就不能通过直接访问类的方式访问到当前的 Director,Director 还得传参给 Scene,这就造成了 Scene 和 Director 互相引用,也就是未定义类型的问题。所以 Director 用单例模式会很方便。 当然,这只是我使用 Cocos2d-x 根据 Cocos 的特性推测着写的,Cocos2d-x 里有自动释放池,写起来估计比我这种山寨版的要好,但是我这个在 Scene 里引用了 graphics.h 头文件,也就是可以在 Scene 里重新定义图形界面的大小,某种意义上会比 Cocos2d 方便。 3、GameScene,LoadScene 这两个类都继承自 Scene,都需要实现 initMySelf 函数,不过如果要实现两个场景之间的切换不能通过互相引用的方法或者分成两个文件,一个头文件,一个 .cpp 文件来实现,头一种会造成发现一个多次重定义的标识符,和未定义标识符的报错,后一种会多出 140 个报错说是什么什么字符已经定义了。总之两个文件不能互相引用,那么就是一个知道另一个,一个不知道另一个,在这种情况下要实现场景的切换就用到了 GameNode 的特性双向链表,比如我是让 LoadScene 文件里引用了 GameScene 的头文件,然后在 LoadScene 的类里包含了 GameScene* scene; 在构造函数的时候 scene = new GameScene; scene->addChild(this); 把自己设为 scene 的子节点,开始游戏时 Director::getInstance()->RunWithScene(scene); 进入 GameScene 在 GameScene 里要变回 LoadScene 只需 Director::getInstance()->RunWithScene(this->getChild()); 就行了。Director 里要是 IsRunning 和 temp 有血缘关系它是不会 delete 掉 temp 的。所以切换场景时这两个场景都不会被清理掉。 以上就是围棋的所有逻辑了,至于代码部分,很长,逻辑都有了就剩搬砖把大楼盖起来,看不下去我的源码也可以根据我的描述写一份自己的了,我相信我描述的够清楚了。 此外,我也给大家分享我收集的其他 编程学习书籍分享: 编程学习视频分享: 整理分享(多年学习的源码、项目实战视频、项目笔记,基础入门教程)最重要的是你可以在群里面交流提问编程问题哦! 欢迎转行和学习编程的伙伴,利用更多的资料学习成长比自己琢磨更快哦!(↓↓↓↓↓↓)