|
|
马上注册,结交更多好友,享用更多功能^_^
您需要 登录 才可以下载或查看,没有账号?立即注册
x
用win1.03+ms c4.0实现民间二吃一棋子小游戏暨学习记录
前段时间研究学习了《小甲鱼老师的Windows sdk程序设计》课程!突然想试一试给手里的Windows1写几个小游戏玩玩 然后。。。。。
没错咱就是傻傻的萌新接连碰壁!但好在坚持也算是终于给出了正式版本!!!!
记得前段时间我曾经上传了一份关于在Windows 1.03上实现2048小游戏!好多友友说看不懂!这次分享我想反正也是笔记正好写的详细一些吧!都是些碎碎念!按照傻新的脑子如果不记录一年后就忘干净了!(ps:这也是咱搞小游戏时为啥会出现过度注释的原因各位大佬们海涵一下!各位萌新们在正式的项目中不要学咱呦~~~)
接下俩是笔记内容!
下面的内容可能太过冗长,源码在第二页哦~
完成效果图:
普通模式
《走四子(二吃一)》技术设计
平台: Microsoft Windows 1.03 SDK + Microsoft C 4.0 语法: K&R C(非 ANSI C) 内存模型: Medium(-AM) 作者: 夏媛媛 协助:sdk文档、《Windows程序设计》第一版、ima(帮咱过滤Windows2及其以后的api)
1.游戏概述
简单介绍一下什么是二吃一也就是《走四子》!这是一款民间棋类游戏,在 4×4 棋盘上进行。两名玩家各有 4 颗棋子,通过移动棋子形成"二吃一"的格局来吃掉对方棋子。谁先把对方吃到只剩 1 颗(无法再被吃),谁就获胜。
1.1 游戏规则
普通模式(二吃一):
- 棋子放在 4×4 网格的交叉点上
- 每次只能向上下左右相邻的空位移动一格(不能斜走)
- 移动后,如果在一条直线上出现"己-己-敌"的连续三子格局,吃掉那颗敌子
- 四子同线不触发吃子
- 对方先连两子,我走入构成三子,不被吃(仅走棋方可吃)
- 谁把对方吃到只剩 1 颗,谁获胜
我还设计了两种模式哦!!!
1.2 咱们要设计的功能
- 普通模式 / 挑战模式切换
- AI 对弈 / 双人对弈切换
- 先后手设置(默认轮流 / 始终先手 / 始终后手)
- 颜色模式(EGA 绿 / VGA 深绿
- 悔棋(每步悔 1 步
- 存档 / 读档 / 删除存档
- 键盘快捷键(N/Z/S/L)
- 无闪烁双缓冲绘制
这样完整的一个小游戏就大概这些内容吧~~~~
演示1
演示2
演示3
2.开发环境与工具链
2.1 开发环境
这是非常困难的环境不过好在我们没必要真的体验DOS3.3码代码的感觉!
ps:开玩笑那个ED真难用!让我想起apple ii和cec-i的basic解释器了!如果你真的想体验记住一下几个坑人的点:
- 加行号(行号相当于图层它决定字符行的顺序所以为了你以后方便插入新内容建议每个行号间隔最少10个数)
- 方向键就是删除(最坑的是屏幕并不会显示!所以修改完内容一定要按方向键将光标拉回字符串末尾)(dos3.3好像能看到删除2.X忘记了好像不行)
我感觉你应该不会在这样的环境里写代码的所以准备一个edit和随便一个现代编辑器吧!
平台:
- DOS 3.33(或者DOSBox 模拟)
- Windows 1.03 运行环境
)
编译工具链:
| 工具 | 版本 | 用途 | | MSC | 4.0 | C 编译器 | | rc | 5.00.12 | 资源编译器 | | LINK4 | 2.0 | 链接器 | | make | 4.0 | 增量编译管理 |
怎么使用呢?
2.2 编译选项详解
记住一下命令即可:
- cl -AM -G2 -Gsw -c -Zpe "文件名.c"
复制代码
cl是编译命令后面的几个参数至关重要分别是:
- “-AM”是Medium 内存模型的意思含义为多代码段(多个 .c 文件),单数据段(所有全局变量共用一个 DGROUP)!与之对应的就是“-as”“-al”小内存模型(代码和数据都在一个64k的段中)和大内存模型(代码和数据都分段)
- “-G2”是80286 指令优化,用于生成 16 位保护模式指令
- “-Gsw”是-Gw + -Gs!分别 在 far 函数入口生成 DS 修复 prolog 与 关闭栈探针(Windows 程序用自己的栈管理)
- "-c"只编译不链接,有多文件项目先分别编译为 .obj,再统一链接不过实际使用下来就算你是单文件链接我还是建议你用这个先生成中间文件!
- “-Zpe”结构体 1 字节对齐,Windows API 要求结构体紧凑排列,不能有填充字节!
- link4 文件名.obj, 文件名.exe, 文件名.map, mlibw mwinlibc, 文件名.def
复制代码
很简单的按照顺序就是要编译的中间文件、输出的可执行文件、导出文本、要连接的lib库、Windows模块定义文件
更简单了就是资源文件编译!如果你不想编译和连接同时进行的话可以加“-r”这样他就会产生res文件不先连接进exe!
2.3 dos环境配置
环境的设置也很重要!如果不不想在每个指令前都要指定文件路径的话!
- set path=c:\msc40\bin;c:\sdk\bin;c:\windows1
- set include=c:\msc40\include;c:\sdk\include
- set lib=c:\msc40\lib;c:\sdk\lib
复制代码
将这些代码加入“autoexec.bat”文件中就可以直接使用外部命令进行操作了!注:建议将sdk的lib放在msc40前面这样系统会先找sdk的库!
若你用dosbox的话可以在配置文件的最下方加入:
- [autoexec]
- mount c 你项目的目录地址
- c:
- set path=c:\msc40\bin;c:\sdk\bin;c:\windows1
- set include=c:\msc40\include;c:\sdk\include
- set lib=c:\msc40\lib;c:\sdk\lib
复制代码
3.c语言的小知识
不知道大家对c语言学习的如何~想必这里的大家一定都是大佬所以可以快速翻过吧这里是独属于傻新的笔记~~
3.1 K&R C 函数定义语法
- /* K&R 语法*/
- BOOL FAR PASCAL AboutDlgProc(hDlg, message, wParam, lParam)
- HWND hDlg; /* 参数类型在下一行声明 */
- unsigned message;
- WORD wParam;
- LONG lParam;
- {
- /* 函数体 */
- }
复制代码
对比 ANSI C 语法
- /* ANSI C 语法(咱们不用) */
- BOOL FAR PASCAL AboutDlgProc(HWND hDlg, unsigned message, WORD wParam, LONG lParam)
- {
- /* 函数体 */
- }
复制代码
我瞬间感觉生活中当代真好但是悲MSC 4.0 不完全支持现代语法!!!!
3.2 变量与数据类型
- int row; /* 16 位整数(MSC 4.0 的 int 是 16 位!)*/
- long dx; /* 32 位整数 */
- char name[32]; /* 字符数组(字符串)*/
- HWND hDlg; /* 窗口句柄(Windows 类型,是 unsigned short 的指针)*/
- BOOL result; /* 布尔值(Windows 类型,是 int)*/
复制代码
但是但是需要注意关键陷阱16 位 int,在 MSC 4.0 中,int 是 16 位的,范围 -32768 ~ 32767。这和现代 C 的 32 位 int 完全不同!!!(比如傻新就栽跟头了)
- /* 错误写法(会溢出) */
- int dx = 300;
- int distance = dx * dx; /* 300 * 300 = 90000,溢出成负数! */
- /* 正确写法 */
- long dx = 300;
- long distance = dx * dx; /* 32 位运算,不会溢出 */
复制代码
3.3 控制结构
if-else:
- if (gameState.gameOver) {
- /* 如果游戏结束 */
- break;
- } else {
- /* 否则 */
- DoAIMove();
- }
复制代码
for 循环:
- /* 遍历 4x4 棋盘 */
- for (row = 0; row < BOARD_SIZE; row++) {
- for (col = 0; col < BOARD_SIZE; col++) {
- gameState.board[row][col] = EMPTY;
- }
- }
复制代码
- row = 0:初始化,循环开始前执行一次
- row < BOARD_SIZE:条件,每次循环前检查,为假则退出
- row++:递增,每次循环后执行
- BOARD_SIZE 是 #define 定义的常量,值为 4
switch-case:
- switch (message) { /* 根据 message 的值跳转 */
- case WM_CREATE: /* 如果 message == WM_CREATE */
- /* 处理创建消息 */
- break; /* 必须 break,否则会继续执行下一个 case */
- case WM_PAINT: /* 如果 message == WM_PAINT */
- /* 处理绘制消息 */
- break;
- default: /* 其他所有值 */
- break;
- }
复制代码
while 循环:
- /* Windows 消息循环 */
- while (GetMessage(&msg, NULL, 0, 0)) { /* GetMessage 返回非 0 时循环 */
- TranslateMessage(&msg); /* 翻译键盘消息 */
- DispatchMessage(&msg); /* 分发消息给窗口过程 */
- }
复制代码
3.4 数组与字符串
- /* 二维数组——棋盘 */
- int board[4][4]; /* 4 行 4 列,board[row][col] */
- /* 字符数组——字符串 */
- char msg[80];
- strcpy(msg, "White wins!"); /* 把字符串复制到 msg */
复制代码
字符串函数(C 运行库):
- strcpy(dest, src):复制字符串
- strcmp(s1, s2):比较字符串,返回 0 表示相等
- sprintf(buf, "Save%d", num):格式化字符串到 buf
3.5 指针
- int *pRow; /* 指向 int 的指针 */
- int value = 5;
- pRow = &value; /* & 取地址,pRow 现在指向 value */
- *pRow = 10; /* * 解引用,通过指针修改 value 为 10 */
复制代码
在函数参数中使用指针(输出参数):
- /* BoardHitTest 函数通过 pRow, pCol 返回点击的行列 */
- BOOL BoardHitTest(mouseX, mouseY, pRow, pCol)
- int mouseX, mouseY;
- int *pRow, *pCol; /* 指针参数,用于返回值 */
- {
- /* 找到点击的格子 */
- *pRow = row; /* 通过指针写回结果 */
- *pCol = col;
- return TRUE;
- }
- /* 调用方式 */
- int row, col;
- if (BoardHitTest(x, y, &row, &col)) { /* 传入 row, col 的地址 */
- /* 使用 row, col */
- }
复制代码
3.6 结构体
- /* 定义结构体类型 */
- typedef struct {
- int board[4][4]; /* 棋盘 */
- int currentPlayer; /* 当前走棋方 */
- int whiteCount; /* 白棋数量 */
- int blackCount; /* 黑棋数量 */
- int gameOver; /* 游戏是否结束(0=进行中,WHITE=白胜,BLACK=黑胜)*/
- /* ... */
- } GameState;
- /* 声明结构体变量 */
- GameState gameState;
- /* 访问成员 */
- gameState.currentPlayer = WHITE;
- gameState.whiteCount = 4;
复制代码
3.7 宏定义
- #define BOARD_SIZE 4 /* 编译时替换:所有 BOARD_SIZE 变成 4 */
- #define MAX_HISTORY 50
- #define WHITE 1
- #define BLACK 2
- #define EMPTY 0
复制代码
这玩意最大好处: 修改常量只需改一处,代码含义更清晰。要是哪里出错了也要找~
3.8 全局变量 vs 局部变量
- /* 全局变量(在所有函数外定义)*/
- int historyCount = 0; /* 所有函数都能访问 */
- void SomeFunction()
- {
- int localVar = 0; /* 局部变量,函数返回后消失 */
- static int counter = 0; /* 静态局部变量,函数返回后保留值 */
- /* ... */
- }
复制代码
3.9 far 和 near
16 位 Windows 中,指针分近(near)和远(far):
- near 指针:16 位,只能寻址当前数据段(DGROUP)内的地址
- far 指针:32 位(段:偏移),可以寻址任何地址
这东西很好理解完完全全就是8086汇编里的东西不过注意到是[b]8086实模式是20位的段地址*16+偏移地址[!而286的保护模式是查表取24位段地址在+16位偏移地址合成24位物理地址/b]这是硬件的规定!但是不论如何在cpu眼中单一基地址的寻址范围始终是16位的也就是0000-FFFF的64kb的空间!
而在这里near 指针就相当于16位,只存偏移,段固定为当前ds、cs段!数据不能超过64kb;far 指针相当于高16=段值,低16位=偏移值,整体被编译器封装成一个32位的类型!数据超过64kb编译器会将这些数据放入多个段中软件层面看相当于空间更大了!(毕竟对于软件自己来说空间地址始终是一维连续的)相应的性能会折损~~
- int near *p; /* near 指针,访问 DGROUP 内的数据 */
- int far *p; /* far 指针,可以访问其他段 */
- /* FAR PASCAL 修饰回调函数 */
- LONG FAR PASCAL MainWndProc(...)
- /* FAR = far 函数(代码在其他段)
- * PASCAL = 调用约定(参数从左到右压栈,被调用者清理栈)
- * Windows 回调函数必须是 FAR PASCAL */
复制代码
要注意的是在 Medium 模型(-am)中,默认函数是 far,默认数据指针是 near。这就是为什么回调函数要标 FAR——确保生成正确的 far 调用。
4. Windows 1.03 编程模型
其实总体下来win16和win32开发除了有些函数名不一样、调用约定有变化、api的总数不同之外基本上设计思路是互通的(我咋感觉我在说废话)!
4.1 消息驱动架构
Windows 程序不是"从头执行到尾"的,而是消息驱动的:
消息循环(WinMain 中):
- while (GetMessage(&msg, NULL, 0, 0)) { /* 从队列取消息,返回 0 表示 WM_QUIT */
- if (hModelessDlg != NULL && IsDialogMessage(hModelessDlg, &msg))
- continue; /* 无模式对话框先处理(Tab 键等)*/
- TranslateMessage(&msg); /* 翻译键盘消息(虚拟键→字符)*/
- DispatchMessage(&msg); /* 分发给窗口过程 */
- }
复制代码
4.2 常用消息
基本上写一个Windows16程序都会用到这些
| 消息 | 触发时机 | 参数含义 | | WM_CREATE | 窗口创建时 | lParam = CREATESTRUCT 指针 | | WM_DESTROY | 窗口销毁时 | 无 | | WM_PAINT | 需要重绘时 | 无(用 BeginPaint 获取 DC) | | WM_LBUTTONDOWN | 鼠标左键按下 | wParam = 按键状态,lParam = 坐标 | | WM_COMMAND | 菜单/按钮/快捷键 | wParam = 命令 ID | | WM_KEYDOWN | 键盘按下 | wParam = 虚拟键码 | | WM_TIMER | 定时器触发 | wParam = 定时器 ID | | WM_ERASEBKGND | 需要擦除背景 | wParam = 设备上下文句柄 | | WM_INITDIALOG | 对话框初始化 | wParam = 第一个子控件的句柄 | | WM_CLOSE | 窗口关闭请求 | 无 | | WM_SYSCOMMAND | 系统菜单命令 | wParam = 命令 ID |
4.3 设备上下文(DC)
DC(Device Context)是 Windows 绘图的抽象层。你不能直接往屏幕画东西,必须先获取它
- HDC hdc; /* 设备上下文句柄 */
- PAINTSTRUCT ps;
- hdc = BeginPaint(hWnd, &ps); /* 在 WM_PAINT 中获取 DC */
- /* 用 hdc 绘图... */
- EndPaint(hWnd, &ps); /* 释放 DC */
复制代码
一般常用的绘图函数有:
- Rectangle(hdc, left, top, right, bottom):画矩形
- Ellipse(hdc, left, top, right, bottom):画椭圆(正方形=圆)
- MoveTo(hdc, x, y):移动画笔到指定位置
- LineTo(hdc, x, y):画线到指定位置
- TextOut(hdc, x, y, string, length):输出文字
- BitBlt(hdcDest, x, y, w, h, hdcSrc, xSrc, ySrc, rop):位块传输
复制代码
相关GDI 对象:
- CreatePen(style, width, color):创建画笔
- CreateSolidBrush(color):创建实心画刷
- SelectObject(hdc, obj):将对象选入 DC(之后用这个对象画)
- DeleteObject(obj):删除对象(防止内存泄漏)
复制代码
颜色: RGB(r, g, b) 宏创建颜色值,每个分量 0-255。
关于GDI!一定要注意有创建就有还!不要的对象要第一时间删除!这在Windows1上很重要(GDI对象有上限溢出会卡死整个系统)
4.4 对话框
Windows1整体来说有两种对话框!!!
模态对话框(DialogBox):
- 阻塞父窗口,用户必须先关闭对话框
- 用 EndDialog(hDlg, result) 关闭
- 自动显示
无模式对话框(CreateDialog):
- 不阻塞父窗口,可以同时操作
- 用 DestroyWindow(hDlg) 关闭
- 不会自动显示,手动 ShowWindow
- 需要在消息循环中用 IsDialogMessage 处理对话框消息
5.开始实现程序结构吧!
这里傻新不得不说按模块构思程序是真的方便后期调试知道吗!!!!我一开始是将这些都塞进一个c文件的结果。。。。!!!
好了总之现在我按照功能分开进行实现整个程序的框架!
首先起一个名字twoeat好了!!!!(注意哦dos不支持长字符名字)
5.1 twoeat.h — 公共头文件
这个文件被所有 .c 文件 #include,定义了所有共享的常量、类型和函数声明。
这行引入 Windows API 的所有声明。没有它,编译器不认识 HWND、BOOL、WM_PAINT 等。
菜单 ID 定义:
- #define IDM_NEWGAME 101
- #define IDM_CHALLENGE 102
复制代码
这些 ID 在 .rc 文件和 .c 文件中共享,确保菜单项和代码中的引用一致。数字必须唯一且 > 100(避免和系统 ID 冲突)。
游戏状态结构体:
- typedef struct {
- int mode; /* MODE_NORMAL 或 MODE_CHALLENGE */
- int board[BOARD_SIZE][BOARD_SIZE]; /* 棋盘数据,0=空,1=白,2=黑 */
- int currentPlayer; /* 当前走棋方(WHITE 或 BLACK)*/
- int firstPlayer; /* 本局先手方 */
- int firstSetting; /* 先后手设置(DEFAULT/FIRST/SECOND)*/
- int twoPlayer; /* 0=AI 模式,1=双人模式 */
- int egaMode; /* 0=EGA 绿,1=VGA 深绿 */
- int whiteCount; /* 白棋剩余数 */
- int blackCount; /* 黑棋剩余数 */
- int selRow, selCol; /* 当前选中的棋子坐标(-1=未选中)*/
- int lastMoveRow, lastMoveCol; /* 最后一步棋的目标位置 */
- int gameOver; /* 0=进行中,WHITE=白胜,BLACK=黑胜 */
- } GameState;
复制代码
记录存放我们游戏状态的地方!!!!
历史记录结构体(用于悔棋):
- typedef struct {
- int board[BOARD_SIZE][BOARD_SIZE];
- int currentPlayer;
- int whiteCount;
- int blackCount;
- int lastMoveRow;
- int lastMoveCol;
- } HistoryEntry;
复制代码
咱们还得悔棋!!!!
5.2 twoeat.c — 主程序文件
咱们游戏所有Windows事件处理分发的地方!是程序的骨架~
全局变量:
- HANDLE hInst; /* 应用程序实例句柄 */
- HWND hWndMain = NULL; /* 主窗口句柄 */
- HWND hModelessDlg = NULL; /* 当前无模式对话框句柄 */
- GameState gameState; /* 游戏状态 */
- BoardLayout boardLayout; /* 棋盘布局参数 */
- HistoryEntry history[MAX_HISTORY]; /* 悔棋历史数组 */
- int historyCount = 0; /* 历史记录总数 */
- int historyCurrent = 0; /* 当前历史指针 */
- HBRUSH hbrBackground = NULL; /* 背景画刷 */
复制代码
为什么用全局变量? 在 16 位 Windows 中,窗口过程和对话框过程被 Windows 系统间接调用,无法方便地传递上下文参数。全局变量是最简单的方式让所有回调函数共享状态。
WinMain — 程序入口:
- int PASCAL WinMain(hInstance, hPrevInstance, lpszCmdLine, nCmdShow)
- HANDLE hInstance; /* 当前实例句柄 */
- HANDLE hPrevInstance; /* 前一个实例句柄(Win 1.03 用于判断是否需要注册类)*/
- LPSTR lpszCmdLine; /* 命令行参数 */
- int nCmdShow; /* 窗口初始显示方式 */
- {
- MSG msg;
- /* 1. 注册窗口类(只在第一个实例时)*/
- if (!hPrevInstance)
- if (!InitApplication(hInstance))
- return FALSE;
- /* 2. 创建主窗口 */
- if (!InitInstance(hInstance, nCmdShow))
- return FALSE;
- /* 3. 开始新游戏 */
- StartNewGame(MODE_NORMAL);
- InvalidateRect(hWndMain, NULL, TRUE); /* 触发重绘 */
- /* 4. 消息循环 */
- while (GetMessage(&msg, NULL, 0, 0)) {
- if (hModelessDlg != NULL && IsDialogMessage(hModelessDlg, &msg))
- continue;
- TranslateMessage(&msg);
- DispatchMessage(&msg);
- }
- return msg.wParam;
- }int PASCAL WinMain(hInstance, hPrevInstance, lpszCmdLine, nCmdShow)
- HANDLE hInstance; /* 当前实例句柄 */
- HANDLE hPrevInstance; /* 前一个实例句柄(Win 1.03 用于判断是否需要注册类)*/
- LPSTR lpszCmdLine; /* 命令行参数 */
- int nCmdShow; /* 窗口初始显示方式 */
- {
- MSG msg;
- /* 1. 注册窗口类(只在第一个实例时)*/
- if (!hPrevInstance)
- if (!InitApplication(hInstance))
- return FALSE;
- /* 2. 创建主窗口 */
- if (!InitInstance(hInstance, nCmdShow))
- return FALSE;
- /* 3. 开始新游戏 */
- StartNewGame(MODE_NORMAL);
- InvalidateRect(hWndMain, NULL, TRUE); /* 触发重绘 */
- /* 4. 消息循环 */
- while (GetMessage(&msg, NULL, 0, 0)) {
- if (hModelessDlg != NULL && IsDialogMessage(hModelessDlg, &msg))
- continue;
- TranslateMessage(&msg);
- DispatchMessage(&msg);
- }
- return msg.wParam;
- }
复制代码
消息循环中的 IsDialogMessage: 如果当前有无模式对话框打开,IsDialogMessage 让对话框先处理消息(如 Tab 键切换控件焦点)。如果对话框处理了,返回 TRUE,跳过 TranslateMessage/DispatchMessage。
InitApplication — 注册窗口类:
- BOOL InitApplication(hInstance)
- HANDLE hInstance;
- {
- WNDCLASS wc;
- wc.style = CS_HREDRAW | CS_VREDRAW; /* 窗口大小变化时重绘 */
- wc.lpfnWndProc = MainWndProc; /* 窗口过程函数指针 */
- wc.hInstance = hInstance;
- wc.hCursor = LoadCursor(NULL, IDC_ARROW); /* 箭头光标 */
- wc.hbrBackground = NULL; /* 自行处理背景 */
- wc.lpszMenuName = "MainMenu"; /* 菜单资源名 */
- wc.lpszClassName = "TwoEatOne"; /* 窗口类名 */
- return RegisterClass(&wc);
- }
复制代码
CS_HREDRAW | CS_VREDRAW:当窗口宽度或高度变化时,Windows 发送 WM_PAINT 重绘整个客户区。棋盘需要根据窗口大小重新布局,所以必须设置。
窗口过程为什么不需要 MakeProcInstance? RegisterClass 的 lpfnWndProc 是窗口类级别的回调,Windows 在内部正确处理了它的 DS。只有对话框过程(通过 DialogBox/CreateDialog 传入的)才需要 MakeProcInstance。
MainWndProc — 主窗口过程:
这是程序的心脏,处理所有发往主窗口的消息。程序需要处理的消息太多喽~~~~我挑几个关键说明吧!
WM_ERASEBKGND(防闪烁关键):
- case WM_ERASEBKGND:
- return 1L; /* 告诉 Windows:背景已处理,不要再擦 */
复制代码
如果在这里 FillRect,Windows 会先用背景色刷一遍屏幕,然后 WM_PAINT 再 BitBlt——两次绘制之间屏幕短暂显示背景色,产生闪烁。返回 1 跳过系统擦除,只靠 BitBlt 一次性更新。
WM_PAINT(双缓冲绘制):
- case WM_PAINT:
- {
- HDC hdc, hMemDC;
- HBITMAP hBitmap, hOldBitmap;
- PAINTSTRUCT ps;
- RECT rc;
- hdc = BeginPaint(hWnd, &ps);
- GetClientRect(hWnd, &rc);
- CalcBoardLayout(rc.right, rc.bottom); /* 计算棋盘布局 */
- /* 双缓冲:先画到内存 DC,再一次性拷贝到屏幕 */
- hMemDC = CreateCompatibleDC(hdc);
- hBitmap = CreateCompatibleBitmap(hdc, rc.right, rc.bottom);
- if (hMemDC != NULL && hBitmap != NULL) {
- hOldBitmap = SelectObject(hMemDC, hBitmap);
- DrawEntireBoard(hMemDC, &rc); /* 画到内存 DC */
- BitBlt(hdc, 0, 0, rc.right, rc.bottom, hMemDC, 0, 0, SRCCOPY);
- SelectObject(hMemDC, hOldBitmap);
- DeleteObject(hBitmap);
- } else {
- /* 内存不足,回退到直接画屏幕 */
- DrawEntireBoard(hdc, &rc);
- }
- if (hMemDC != NULL)
- DeleteDC(hMemDC);
- EndPaint(hWnd, &ps);
- return 0L;
- }
复制代码
这个其实还有问题我现在只解决了与其他程序平铺时刷新不闪烁,但是自己独占屏幕时还是闪烁只不过闪烁的快了!
原理:
- CreateCompatibleDC:创建一个与屏幕兼容的内存设备上下文
- CreateCompatibleBitmap:创建一个与屏幕兼容的位图
- SelectObject:把位图选入内存 DC(之后画在内存 DC 上的内容都画在这个位图上)
- DrawEntireBoard(hMemDC, ...):在内存中画好所有内容
- BitBlt(hdc, ..., SRCCOPY):一次性把内存中的画面拷贝到屏幕
WM_COMMAND(菜单命令):
- case WM_COMMAND:
- {
- switch (wParam) {
- case IDM_NEWGAME:
- StartNewGame(MODE_NORMAL);
- InvalidateRect(hWnd, NULL, TRUE);
- break;
- case IDM_SAVE:
- if (hModelessDlg != NULL)
- BringWindowToTop(hModelessDlg);
- else {
- lpProcSave = MakeProcInstance((FARPROC)SaveDlgProc, hInst);
- if (lpProcSave == NULL) break;
- hModelessDlg = CreateDialog(hInst, "SaveDlg", hWnd, lpProcSave);
- if (hModelessDlg != NULL) {
- ShowWindow(hModelessDlg, 1);
- BringWindowToTop(hModelessDlg);
- }
- }
- break;
- /* ... */
- }
- }
复制代码
MakeProcInstance 的作用:简单说,它创建一个"thunk"(转换器),在调用对话框过程前把 DS 寄存器设置为正确的数据段。不加这个,对话框过程中读写全局变量会命中随机内存。(很碍事的bug点)
WM_TIMER(AI 走棋):
- case WM_TIMER:
- {
- if (wParam == IDT_AI_MOVE) {
- KillTimer(hWnd, IDT_AI_MOVE); /* 先杀定时器,防止重入 */
- if (hModelessDlg != NULL) { /* 对话框打开时跳过 */
- SetTimer(hWnd, IDT_AI_MOVE, 500, NULL);
- break;
- }
- if (!gameState.gameOver && gameState.currentPlayer == BLACK && !gameState.twoPlayer) {
- DoAIMove();
- InvalidateRect(hWnd, NULL, FALSE);
- if (gameState.gameOver) {
- EndGame(gameState.gameOver);
- }
- }
- }
- break;
- }
复制代码
5.3 board.c — 棋盘绘制
这个设计我是采用Windows1自带游戏黑白棋界面进行的!
2048 256色界面
这源于我之前编写的2048的界面在增加缩放时显得很突兀,是的就是丑!后来我分析了一下丑陋来源于配色、界面搭配!还有违反人类视角的游戏客户区大小!
版面设计中一个合格的棋盘类、桌面矩阵游戏的视角设计点是什么?我想是可以在合适的大小内整体出现在人类的视角正中!
太大边缘棋子和元素就会超过视角会跟人一种看不过咯来的既视感!太小又会过于拥挤!居中的设计会自然汇聚重心~
16色下的美术是的Windows官方驱动只能支持16色,不过好在官方驱动会自动抖动超出16色之外的颜色。我们可以在这里绘制棋盘的基座和网格!以及让基座具有悬浮感的阴影!
游戏界面设计
CalcBoardLayout — 计算布局
根据窗口客户区大小,计算棋盘的居中位置、格子大小、棋子半径等。
DrawEntireBoard — 绘制完整棋盘
依次绘制:背景 → 底座阴影 → 底座 → 网格线 → 棋子 → 选中标记 → 最后一步标记。
BoardHitTest — 点击命中检测
- BOOL BoardHitTest(mouseX, mouseY, pRow, pCol)
- int mouseX, mouseY;
- int *pRow, *pCol;
- {
- int row, col;
- int px, py;
- long dx, dy; /* long!不是 int!*/
- long radius2;
- radius2 = (long)boardLayout.cellSize * boardLayout.cellSize / 4;
- for (row = 0; row < BOARD_SIZE; row++) {
- for (col = 0; col < BOARD_SIZE; col++) {
- GetPieceCenter(row, col, &px, &py);
- dx = (long)(mouseX - px);
- dy = (long)(mouseY - py);
- if (dx * dx + dy * dy <= radius2) { /* 圆形判定 */
- *pRow = row;
- *pCol = col;
- return TRUE;
- }
- }
- }
- return FALSE;
- }
复制代码
注意为什么用 long? dx * dx 在 dx > 181 时就超过 16 位 int 的最大值 32767。棋盘上两个棋子间距轻松超过 300 像素,300 * 300 = 90000 溢出成负数,导致远处的棋子因溢出"通过"判定,近处的反而被跳过。
5.4 game.c — 游戏逻辑
InitBoard — 初始化棋盘
- void InitBoard()
- {
- int row, col;
- for (row = 0; row < BOARD_SIZE; row++)
- for (col = 0; col < BOARD_SIZE; col++)
- gameState.board[row][col] = EMPTY;
- /* 第 0 行放黑棋(AI),第 3 行放白棋(玩家)*/
- for (col = 0; col < BOARD_SIZE; col++) {
- gameState.board[0][col] = BLACK;
- gameState.board[3][col] = WHITE;
- }
- }
复制代码
MakeMove — 执行走棋
- void MakeMove(fromRow, fromCol, toRow, toCol)
- int fromRow, fromCol, toRow, toCol;
- {
- int player;
- int captured;
- SaveHistoryEntry(); /* 1. 先保存当前状态(用于悔棋)*/
- player = gameState.board[fromRow][fromCol];
- /* 2. 移动棋子 */
- gameState.board[fromRow][fromCol] = EMPTY;
- gameState.board[toRow][toCol] = player;
- /* 3. 记录最后走棋位置 */
- gameState.lastMoveRow = toRow;
- gameState.lastMoveCol = toCol;
- /* 4. 检查吃子 */
- captured = CheckCapture(toRow, toCol, player);
- /* 5. 更新棋子计数 */
- if (captured > 0) {
- if (player == WHITE)
- gameState.blackCount -= captured;
- else
- gameState.whiteCount -= captured;
- }
- /* 6. 检查胜负 */
- if (player == WHITE && gameState.blackCount <= 1) {
- gameState.gameOver = WHITE;
- return;
- }
- if (player == BLACK && gameState.whiteCount <= 1) {
- gameState.gameOver = BLACK;
- return;
- }
- /* 7. 切换走棋方 */
- gameState.currentPlayer = (player == WHITE) ? BLACK : WHITE;
- /* 8. 检查对方是否无棋可走 */
- if (!CanPlayerMove(gameState.currentPlayer)) {
- gameState.gameOver = player; /* 对方无路可走,我方胜 */
- }
- }
复制代码
?: 三元运算符:
- gameState.currentPlayer = (player == WHITE) ? BLACK : WHITE;
- /* 等价于:
- if (player == WHITE)
- gameState.currentPlayer = BLACK;
- else
- gameState.currentPlayer = WHITE;
- */
复制代码
5.5 ai.c — AI 逻辑
使用 Minimax + Alpha-Beta 剪枝算法,搜索深度 3 层。
DoAIMove — AI 走棋
- void DoAIMove()
- {
- int moves[32][4]; /* 候选走法数组 [fromRow, fromCol, toRow, toCol] */
- int moveCount;
- int bestMove;
- int bestScore;
- GetValidMoves(gameState.board, BLACK, moves, &moveCount);
- if (moveCount == 0) return;
- bestMove = 0;
- bestScore = -INFINITY;
- /* 遍历所有走法,找评分最高的 */
- for (i = 0; i < moveCount; i++) {
- CopyBoard(tempBoard, gameState.board);
- SimMove(tempBoard, moves[i][0], moves[i][1], moves[i][2], moves[i][3]);
- score = MinValue(tempBoard, AI_DEPTH - 1, -INFINITY, INFINITY, WHITE);
- if (score > bestScore) {
- bestScore = score;
- bestMove = i;
- }
- }
- MakeMove(moves[bestMove][0], moves[bestMove][1],
- moves[bestMove][2], moves[bestMove][3]);
- }
复制代码
Minimax 算法
AI 最大化自己的分数(MaxValue),假设对手最小化 AI 的分数(MinValue)。Alpha-Beta 剪枝跳过明显不好的分支,加速搜索。
- AI(黑棋)→ MaxValue:找最大值
- ↓
- 玩家(白棋)→ MinValue:找最小值
- ↓
- AI(黑棋)→ MaxValue:找最大值
- ↓
- 到达深度限制 → 评估局面
复制代码
我承认我自己没赢过!
5.6 dialogs.c — 对话框
对话框一共有两种大概都差不多的设计~
AboutDlgProc / HelpDlgProc — 模态对话框
- BOOL FAR PASCAL AboutDlgProc(hDlg, message, wParam, lParam)
- HWND hDlg;
- unsigned message;
- WORD wParam;
- LONG lParam;
- {
- switch (message) {
- case WM_INITDIALOG:
- return TRUE;
- case WM_COMMAND:
- if (wParam == IDOK || wParam == IDCANCEL) {
- EndDialog(hDlg, TRUE);
- return TRUE;
- }
- break;
- }
- return FALSE; /* 未处理的消息返回 FALSE */
- }
复制代码
SettingsDlgProc — 无模式设置对话框
- case WM_INITDIALOG:
- {
- /* 根据当前游戏状态初始化单选按钮 */
- CheckRadioButton(hDlg, IDC_SET_DEFAULT, IDC_SET_SECOND,
- IDC_SET_DEFAULT + gameState.firstSetting);
- return TRUE;
- }
- case IDOK:
- {
- /* 读取用户选择并更新游戏状态 */
- if (IsDlgButtonChecked(hDlg, IDC_SET_DEFAULT))
- gameState.firstSetting = FIRST_DEFAULT;
- else if (IsDlgButtonChecked(hDlg, IDC_SET_FIRST))
- gameState.firstSetting = FIRST_ALWAYS;
- else
- gameState.firstSetting = SECOND_ALWAYS;
- /* 更新背景画刷 */
- if (hbrBackground != NULL)
- DeleteObject(hbrBackground);
- if (gameState.egaMode == 0)
- hbrBackground = CreateSolidBrush(RGB(0, 128, 0));
- else
- hbrBackground = CreateSolidBrush(RGB(0, 64, 0));
- InvalidateRect(hWndMain, NULL, TRUE); /* 重绘主窗口 */
- DestroyWindow(hDlg); /* 关闭无模式对话框 */
- hModelessDlg = NULL;
- return TRUE;
- }
复制代码
需要注意的是:Win 1.03 的 RADIOBUTTON 不会自动切换: 点击一个单选按钮后,需要手动调 CheckRadioButton 让同组其他按钮取消选中。Win 3.0+ 的 AUTORADIOBUTTON 才有自动行为。
5.7 save.c — 存档/读档
SaveGame — 保存游戏
- BOOL SaveGame(saveName)
- char *saveName;
- {
- SaveData data;
- char filePath[32];
- int fd;
- int foundSlot;
- /* 找一个空的存档槽位 */
- foundSlot = FindFreeSlot();
- /* 填充存档数据 */
- data.magic[0] = 'T'; data.magic[1] = 'E';
- data.magic[2] = 'O'; data.magic[3] = '1';
- data.version = 1;
- /* ... 复制游戏状态 ... */
- strcpy(data.saveName, saveName);
- data.timestamp = time(NULL);
- /* 写入文件 */
- GetSaveFileName(filePath, foundSlot);
- fd = open(filePath, O_WRONLY | O_CREAT | O_BINARY | O_TRUNC, S_IREAD | S_IWRITE);
- if (fd < 0) return FALSE;
- write(fd, (char *)&data, sizeof(SaveData));
- close(fd);
- return TRUE;
- }
复制代码
Win 1.03 没有 _lread/_lwrite/_lclose 这些 API。用 C 运行库的 open/write/close 是最可靠的选择~
6. 资源文件详解
这是Windows编程设计里一个十分先进的地方,资源和源码分开有助于国际化、好编辑的优点!这不是书里说的这就是傻新说的英文这东西让咱可以在Windows1程序里挂载汉字~
6.1 菜单(MENU)
- MainMenu MENU
- BEGIN
- POPUP "Game"
- BEGIN
- MENUITEM "New Game", IDM_NEWGAME
- MENUITEM "Challenge Mode", IDM_CHALLENGE
- MENUITEM "Undo", IDM_UNDO
- MENUITEM SEPARATOR
- MENUITEM "Save", IDM_SAVE
- MENUITEM "Load", IDM_LOAD
- MENUITEM "Settings", IDM_SETTINGS
- MENUITEM SEPARATOR
- MENUITEM "Help", IDM_HELP
- MENUITEM "About", IDM_ABOUT
- END
- POPUP "Mode"
- BEGIN
- MENUITEM "vs AI", IDM_AI_MODE
- MENUITEM "Two Players", IDM_TWO_PLAYER
- END
- END
复制代码
解析:
- POPUP:弹出式子菜单
- MENUITEM:菜单项,后面跟显示文字和命令 ID
- MENUITEM SEPARATOR:分隔线
6.2 对话框(DIALOG)
- SaveDlg DIALOG LOADONCALL MOVEABLE DISCARDABLE 22, 17, 180, 120
- CAPTION "Save Game"
- STYLE WS_POPUP | WS_CAPTION | WS_DLGFRAME
- BEGIN
- LTEXT "Save Name:", -1, 10, 10, 60, 10
- EDITTEXT IDC_SAVE_EDIT, 10, 22, 160, 12, ES_AUTOHSCROLL
- LISTBOX IDC_SAVE_LIST, 10, 54, 160, 36, WS_VSCROLL | WS_TABSTOP
- DEFPUSHBUTTON "OK", IDOK, 10, 100, 35, 14
- PUSHBUTTON "Delete", IDC_SAVE_DELETE, 55, 100, 35, 14
- PUSHBUTTON "Cancel", IDCANCEL, 135, 100, 35, 14
- END
复制代码
格式: 控件类型 "文字", ID, x, y, width, height, style
我整理了一下大概常用的各种属性和风格:
控件类型:
- LTEXT:左对齐静态文字(不可编辑)
- CTEXT:居中静态文字
- EDITTEXT:可编辑文本框
- LISTBOX:列表框
- DEFPUSHBUTTON:默认按钮(按回车触发)
- PUSHBUTTON:普通按钮
- RADIOBUTTON:单选按钮
- GROUPBOX:分组框(视觉容器)
对话框属性:
- LOADONCALL:需要时加载
- MOVEABLE:可在内存中移动
- DISCARDABLE:内存不足时可丢弃
窗口风格:
- WS_POPUP:弹出式窗口(对话框必须)
- WS_CAPTION:有标题栏
- WS_DLGFRAME:对话框边框
- WS_VSCROLL:垂直滚动条
- WS_TABSTOP:可以用 Tab 键切换到此控件
- WS_GROUP:分组起点(单选按钮分组用)
- ES_AUTOHSCROLL:文本框自动水平滚动
7. Bug 与问题完整史
怎么说呢真的是尝试之后的血泪史,记录一下遇到的各种革沿的bug吧
7.1 DOS 8.3 文件名限制
问题: 文件最初叫 twoeatone(9 字符),make/link 找不到文件。
原因: DOS 文件名最多 8 字符 + 3 字符扩展名。twoeatone.c 会截断成 TWOEAT~1.C,make 和 link 无法匹配。
7.2 -Gsw 编译选项
问题: 程序运行后全局变量值错乱,GDI 调用异常,字符串显示乱码。
错误示例
然后!!!
段错误
所以编译的时候一定要加上“-Gsw”!
- cl -c -Asnw -Gsw -Os -Zdpe 文件名.c
复制代码
正常现象
7.3调用 MessageBox
问题: 存档后偶发卡死。
原因: 在无模式对话框的 IDOK 处理中调用了 MessageBox。MessageBox 会进入嵌套消息循环,导致对话框过程重入。
- /* 错误 */
- case IDOK:
- if (SaveGame(saveName)) {
- MessageBox(hDlg, "Game saved!", "Save", MB_OK); /* ← 嵌套消息循环!*/
- }
- DestroyWindow(hDlg);
- break;
复制代码
修复: 移除成功时的 MessageBox,只在失败时提示。
7.4 16 位整数溢出(坐标运算)
问题: 点击棋盘底部棋子时,无反应。
发现过程:偶然间点到棋盘右上角区域左下角棋子被选中!
原因: dx * dx 在 dx > 181 时溢出(181 * 181 = 32761,接近 16 位最大值 32767)。棋盘上间距轻松超过 300 像素。
- /* 错误(int 是 16 位) */
- int dx, dy;
- int radius2;
- radius2 = boardLayout.cellSize * boardLayout.cellSize / 4;
- if (dx * dx + dy * dy <= radius2) { /* 300*300=90000 溢出成负数 */
- /* 远处的棋子因溢出"通过"判定 */
- }
复制代码
修复:
- /* 正确(用 long,32 位) */
- long dx, dy;
- long radius2;
- radius2 = (long)boardLayout.cellSize * boardLayout.cellSize / 4;
- if (dx * dx + dy * dy <= radius2) { /* 32 位运算,不会溢出 */
- /* 正确的命中判定 */
- }
复制代码
7.5 CreateCompatibleBitmap 失败
问题: 窗口最大化时偶发绘制异常。
全屏错误
原因: CreateCompatibleBitmap 在大窗口下可能返回 NULL(16 位环境内存有限)。
修复: 加 NULL 检查,失败时回退到直接画屏幕。
- if (hMemDC != NULL && hBitmap != NULL) {
- /* 双缓冲 */
- } else {
- /* 回退到直接画屏幕 */
- if (hBitmap != NULL) DeleteObject(hBitmap);
- DrawEntireBoard(hdc, &rc);
- }
复制代码
全屏错误修复
7.6 DEF文件导出异常
这个是最头疼的bug
报错
如图所示这样的报错发生在link中!
解决方案也挺简单整个DEF文件在edit编辑器里编辑不要用任何现代编辑器!相信我一定不会在报错~
8.def模块定义文件
- NAME TWOEAT
- DESCRIPTION 'Two Eat One Board Game for Windows 1.03'
- STUB 'WINSTUB.EXE'
- CODE PRELOAD MOVEABLE
- DATA PRELOAD MOVEABLE MULTIPLE
- HEAPSIZE 2048
- STACKSIZE 4096
- EXPORTS
- MainWndProc
- AboutDlgProc
- HelpDlgProc
- SettingsDlgProc
- SaveDlgProc
- LoadDlgProc
复制代码
逐行解释:
- NAME TWOEAT:声明这是一个 Windows 程序(不是 DLL),模块名为 TWOEAT
- STUB 'WINSTUB.EXE':如果程序在 DOS 下直接运行,显示"需要 Windows"的提示
- CODE PRELOAD MOVEABLE:代码段在程序启动时加载,可以在内存中移动(Windows 内存管理需要)
- DATA PRELOAD MOVEABLE MULTIPLE:数据段预加载,可移动,MULTIPLE 表示每个实例有自己的数据段副本
- HEAPSIZE 2048:堆大小 2KB(动态分配用)
- STACKSIZE 4096:栈大小 4KB
- EXPORTS:列出所有被 Windows 外部调用的回调函数。Windows 通过这个表找到这些函数的地址。窗口过程和对话框过程必须导出。
基本上完整的程序就已经完成!剩下的就是完整的编译、连接、运行了!!!
- cl -AM -G2 -Gsw -c -Zpe twoeat.c
- cl -AM -G2 -Gsw -c -Zpe board.c
- cl -AM -G2 -Gsw -c -Zpe game.c
- cl -AM -G2 -Gsw -c -Zpe ai.c
- cl -AM -G2 -Gsw -c -Zpe dialogs.c
- cl -AM -G2 -Gsw -c -Zpe save.c
- link4 twoeat+board+game+ai+dialogs+save, twoeat.exe, twoeat.map, mlibw mwinlibc, twoeat.def
- rc twoeat.rc
复制代码
挑战模式
256色 暗绿
256色 绿
关于
|
|