桌面山寨版2048---界面虽无聊,细节很重要
弄个个人独立的博客是我的一个一直存在想法,毕竟不想当优秀工程师的程序员不是好码农,所以,我一直希望有个独立的博客来记录和分享自己的想法,最重要的希望能有一个平台认识很多志同道合的人。但是弄完了之后怎样让别人知道我是志同道合的自己人又让我陷入了沉思,百般思考之后,我觉得在这里更新日志是一个既不会被封杀又最有效的办法,所以在我的博客没有稳定之前,我会一直在这里和独立博客中一起更新,直到我的博客能够猥名远扬,被大家所知晓,希望我能成功吧!(这一节在博客那边要不要去掉呢?思前想后,我还是去掉了)
请猛戳:http://www.richinmemory.com/
第一次玩2048的时候是github上的那个网页版,当时真的是根本停不下来,玩着玩着我突然冒出一个想法,这游戏操作啥的都不复杂,而且最近下班闲着的无聊时光还蛮多的,所以我觉得我可以试试能不能山寨一个。既然要山寨就得要山寨的专业,虽然这是个开源的项目,但是我 没有看过一行他本身的代码,因为我要做个有节操的山寨者。考虑到语言我最熟悉的莫过于的c/c++了,所以我也只能选择他了。在山寨的过程中,我深深的体会到了任何一个可以拿到给大家使用的软件都是不能大意的,一个小小的细节上的没有注意导致的就是别人在使用上的bug。
一、2048山寨货之界面篇
界面是我要写的三篇里面最无聊的一篇了,毕竟不管在技术还是逻辑上,都是涉及的最少的地方,不过既然要山寨就得山寨的专业,所以首先我决定对于配色、画面的比例来个专业点的山寨,于是,最开始,我研究出来的界面比例和颜色啥的是这样的:
但是我最终做出来的界面是这样的:
这些个比例如何得到呢?我原先的想法是用截图然后用PS量,是不是听起来特别的有诚意,但实际上,看看这字体大小,位置,方块的颜色,唉!实际我的做法是在代码里拍脑袋的决定,然后调试之后发现没有遮挡和看着还行就可以了。事实再一次说明懒惰和凑合绝逼是普通人和牛人之间的一个重大差距啊!这么看来处女座的程序员应该更容易成为大牛!
这个游戏在设计上,我使用四个矩形来进行布局,一个是左上角的名称,然后是右上角的最高分和当前得分,最后是游戏区域。
虽然不是按照完美的山寨的理想,但是我还是有一些设计的,我没有采用固定坐标的方法来排布界面是因为我当时考虑到在不同分辨率的电脑上希望能有一个看起来差不多的界面比例。但是我最初在这里的一个细节上的错误导致了在不同的电脑上界面的极端不一致,虽然我现在进行了改进,但是还不是最完美的方案,如果有兴趣,可以继续修改。
最初我的代码大致是这样的:
m_rtGameBorder.left = m_rtClient.right/8; m_rtGameBorder.top = m_rtClient.bottom/6; m_rtGameBorder.right = 7*m_rtClient.right/8; m_rtGameBorder.bottom = m_rtClient.bottom/6 + 3*m_rtClient.right/4;
对于除法,我直接使用的是整数除以整数,这样导致四舍五入之后误差较大,在一些电脑上可能界面刚刚好,但是在另外一些电脑上,就会导致重叠。所以最终我把代码改成了这样:
m_rtGameBorder.left = (double)m_rtClient.right/8.0; m_rtGameBorder.top = (double)m_rtClient.bottom/6.0; m_rtGameBorder.right = 7.0*(double)m_rtClient.right/8.0; m_rtGameBorder.bottom = (double)m_rtClient.bottom/6.0 + 3.0*(double)m_rtClient.right/4.0;
这样的代码在某种方面解决了一些问题,但毕竟不是真正的完美解决方案,如果想真正的完美解决,可以参考GDI里面关于映射方式的内容。
名称区和得分区没有什么特别的技术含量,只要你选好颜色,调好坐标,调用MFC 的画图函数就可以做到,这里的得分区的配色我是完全山寨的原来游戏,名称区嘛,凑合一下好了,哈哈。
对于游戏区,主要分三个部分,外面的大边框,格子线和每个游戏方块。
我从一开始就决定这个山寨游戏自由度要高一点,不仅可以4x4,还可以5x5等等,所以我留下了一个接口,在后面我还会对此进行说明。这个接口函数使用两个参数,重要的一个是行/列数,主要方法就是使用边长为(行数+1)的小正方形进行挨个填充,这样我想的一个好处是可以用剩下的空间作为这些小正方形的间隔距离。比如说,填充一个4x4的游戏区域,假设我们的大游戏框架是一个600像素x600像素的区域,那么每个小的游戏方块就是边长为600/(4+1)=120像素大小的正方形,这样会剩下一个120大小的区域没有填充,这时正好将这个120分成5份,成为这4个正方形之间的间隔。写成代码我反而觉得更容易理解这个逻辑:
void CChildView::DrawGrid(int nRowAndCol,CPaintDC *dc) { CRect tmpRect; tmpRect.CopyRect(&m_rtGrid); for (int i=0;i<m_nRowAndCol;i++) { for(int j=0;j<m_nRowAndCol;j++) { dc->FillRect(&tmpRect,&m_brhGrid); tmpRect.left = tmpRect.left + tmpRect.Width() + m_rtGameBorder.Width()/(double)((m_nRowAndCol+1)*(m_nRowAndCol+1)); tmpRect.right = tmpRect.left + m_rtGameBorder.Width()/(double)(m_nRowAndCol+1); } tmpRect.left = m_rtGameBorder.left + m_rtGameBorder.Width()/(double)((m_nRowAndCol+1)*(m_nRowAndCol+1)); tmpRect.right = tmpRect.left + m_rtGameBorder.Width()/(double)(m_nRowAndCol+1); tmpRect.top = tmpRect.top + tmpRect.Height() + m_rtGameBorder.Width()/(double)((m_nRowAndCol+1)*(m_nRowAndCol+1)); tmpRect.bottom = tmpRect.top + m_rtGameBorder.Width()/(double)(m_nRowAndCol+1); } return; }
说完了游戏区域,下面主要的一个就是一个个的游戏方块了,对于这个方块,我想简单的封装一下。我想了想2048的方块,总结了下,这些个要素是必须的:数字(文字)、颜色、当前的位置。虽然说这颜色和文字是一一对应的,但是我觉得这个冗余设置会在编码上带来方便。除了这三个信息,我还使用的两个信息是bshow 和bjoin,第一个是用来标识当前游戏块是否显示状态,后面一个是为了表示当前代码块是否被合并过,这两个成员变量都对我的编码带来了极大的方便。做这样一个小小的封装还有一个好处就是在后面如果想进行扩展,会十分的方便。这一点在后面三篇文章中更会感受的更加深刻。
#pragma once class ItemBox { public: ItemBox(void); ~ItemBox(void); public: int nRowIndex; int nColIndex; CString strItemText; COLORREF crItem; bool bShow; bool bJoin; };
在使用这个简单的封装以后,在程序的一开始就利用一个行数×行数的数组,这个数组里是一个个的ItemBox,然后在一定时候初始化它们就可以了。我这样做的目的主要是在程序一开始的时候全部建立起来,这样只要在析构的时候全部销毁就不会造成内存的问题,相比只有在游戏方块出现的时候再new一个新的,这种方法不仅可以减少编程的复杂性而且可以防止造成内存的问题。
建立起这样的数组,下面的问题就是如何在游戏区域的指定位置绘画出游戏方块了,我想,最自然的思维方式就是根据方块的坐标在指定位置绘画出其图形,这也是为什么要在封装的信息里提供一个位置的信息。根据最熟悉的二维笛卡尔坐标体系,首先我们得给所有的方格赋予初值,这些初值包括,每一个游戏方块的纵坐标,横坐标,文字,颜色,是否显示(自然是不显示),是否被合并过(貌似名字叫bjoined更靠谱)。然后按照我们得产生两对不一样的坐标,因为根据其规则最开始是有两个方块产生的。那么就不得不使用随机数了,而且要保证这个坐标不能越界,这里我使用了一个简单的随机数函数。值得注意的细节就是这个随机数一定要设置种子,因为如果不是这样的话,在后面大量需要随机数的时候就会很容易产生相同的坐标。还有一个就是即使是最开始产生的这两个坐标,也一定要保证其不能相同,我采用的是一个循环检测的办法。这里就要使用是否当前位置的游戏块是显示状态,如果是,那么就继续产生坐标,直到找到未显示的块位置为止。
void CChildView::InitializeItemBoxes() { for(int i=0; i<m_nRowAndCol*m_nRowAndCol; i++) { m_itemBoxArray[i].nRowIndex = i/m_nRowAndCol; m_itemBoxArray[i].nColIndex = i%m_nRowAndCol; m_itemBoxArray[i].strItemText = _T(""); m_itemBoxArray[i].crItem = m_arrClrItems[1]; m_itemBoxArray[i].bShow = false; m_itemBoxArray[i].bJoin = false; //DrawNumber(m_itemBoxArray[i]); } GetRand(4.0,0.0); int nCol = GetRand(4.0,0.0); int nRow = GetRand(4.0,0.0); int nCol2 = GetRand(4.0,0.0); int nRow2 = GetRand(4.0,0.0); m_itemBoxArray[nRow*4+nCol].bShow = true; m_itemBoxArray[nRow*4+nCol].strItemText = m_arrStrItemTexts[0];// _T("2"); m_itemBoxArray[nRow*4+nCol].crItem = m_arrClrItems[1]; while(m_itemBoxArray[nRow2*4+nCol2].bShow) { nCol2 = GetRand(4.0,0.0); nRow2 = GetRand(4.0,0.0); } m_itemBoxArray[nRow2*4+nCol2].bShow = true; m_itemBoxArray[nRow2*4+nCol2].strItemText = m_arrStrItemTexts[0];// _T("2"); m_itemBoxArray[nRow2*4+nCol2].crItem = m_arrClrItems[1]; }
这些都做完了,接着就是如何将这些和MFC的OnPaint结合了,如何绘制出一个启动的出是画面。我采用的方法是利用上面说的bshow,如果当前位置需要显示方块,那么就选取相应的颜色,输出相应的文字,这三点就是前面封装的三个成员变量。如果不需要,那么就选取方块的本身的背景颜色。这样就造成了一种某个方块没有显示的假象,在OnPaint的每次重绘里,遍历所有方块区域,然后根据设置对每个方块进行处理,是要绘制出相应的游戏方块,还是只是绘制背景色。另外,我把分数的更新也放在了这里。
int CChildView::DrawNumber(const ItemBox& itembox) { CRect rtNumber; CBrush brhNumber; rtNumber.left = m_rtGrid.left + itembox.nColIndex*m_rtGrid.Width() + itembox.nColIndex* m_rtGameBorder.Width()/(double)((m_nRowAndCol+1)*(m_nRowAndCol+1)); rtNumber.right = rtNumber.left + m_rtGameBorder.Width()/(double)(m_nRowAndCol+1); rtNumber.top = m_rtGrid.top + itembox.nRowIndex*m_rtGrid.Height() + itembox.nRowIndex*m_rtGameBorder.Width()/(double)((m_nRowAndCol+1)*(m_nRowAndCol+1)); rtNumber.bottom = rtNumber.top + m_rtGameBorder.Width()/(double)(m_nRowAndCol+1); COLORREF clrItemBackground = m_arrClrItems[GetIndex(itembox.strItemText)]; if(itembox.bShow) brhNumber.CreateSolidBrush(clrItemBackground); else brhNumber.CreateSolidBrush(RGB(205,193,179)); CClientDC dc(this); dc.SelectObject(&m_itemFont); dc.FillRect(&rtNumber,&brhNumber); // DBDBDB SetBkColor(dc.m_hDC,clrItemBackground); LOGFONT lf; m_itemFont.GetLogFont(&lf); CSize sz; TEXTMETRIC tm; sz = dc.GetTextExtent(itembox.strItemText); dc.GetTextMetrics(&tm); int yOffset = (rtNumber.Height()-lf.lfHeight)/2; int xOffset = (rtNumber.Width()- sz.cx)/2; dc.TextOut(rtNumber.left+xOffset,rtNumber.top+yOffset,itembox.strItemText); dc.SelectObject(&m_scoreNumberFont); dc.SetTextColor(RGB(250,248,239)); SetBkColor(dc.m_hDC,RGB(187,173,160)); CString strScore; strScore.Format(_T("%d"),m_nScore); dc.TextOut(m_rtScore.left+10,m_rtScore.top+35,strScore); m_nBestScore = m_nScore>=m_nBestScore?m_nScore:m_nBestScore; CString strBestScore; strBestScore.Format(_T("%d"),m_nBestScore); dc.TextOut(m_rtBest.left+10,m_rtBest.top+35,strBestScore); return 0; }
关于界面篇,就只有这么多了,感觉这篇我写的很无聊,全靠代码凑的数,但是这个小游戏的界面还是比较简单,需要注意的应该主要是细节,而大部分细节又被我凑合掉了。但是,说实话,界面绝对是一个产品成功与否的关键,所以如果不是山寨而是自己原创的东西的话,一定要画大心思在界面上。除非你的软件的用处是从全世界的银行账户中都转10块钱到自己的账户里,那样无论你的界面有多丑,按钮有多难找,有多难操作,用户都会去孜孜不倦的寻找正确的用法。还有一点就是,做桌面版软件一定要在多个电脑上测试,因为可能一切界面在你的电脑中良好,到另外一个电脑里就是面目全非了。
界面篇写完了,下面是逻辑部署篇和优化篇,这两篇从字面上就比界面有意思,但是没有界面再好的软件也出不来,咋说呢,感兴趣的先忍忍,我拼尽全力不让忍忍的朋友失望。
另外:感兴趣,想所要源代码的同学,可以去我的博客(那里有邮箱喔),再次请猛戳(http://www.richinmemory.com/),我仿佛已经听到每个人心中的骂声,毕竟我的一大目的是希望自己的博客能够猥名远扬。所以,先忍忍,我还是会拼尽全力不让忍忍的朋友失望。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· .NET周刊【3月第1期 2025-03-02】
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· [AI/GPT/综述] AI Agent的设计模式综述