简介这份压缩包面向CUPT物理竞赛中的水瓶课题内含一份基于Visual C编写的水瓶物理模拟源码适合正在备赛的高校学生以及希望入门C物理模拟与游戏开发的开发者。包体非常精简仅含1个cpp源文件压缩后大小约1KB该源文件承载全部主程序逻辑便于逐行阅读与调试。目前已有451人浏览学习。源码围绕“水瓶受到外力作用后内部水的运动状态变化”展开借助动量守恒、牛顿运动定律和流体动力学的思想建立简化模型在代码中定义水瓶与水对象计算受击后的运动轨迹并尝试进行可视化交互同时为游戏场景中的实时模拟与操作反馈预留扩展空间。读者可以从中学习物理问题到程序实现的转换思路、类的组织方式以及基于Visual C的基础图形处理流程也可将其二次开发为完整的竞赛方案或小型物理游戏是一份麻雀虽小却五脏俱全的跨学科学习素材。1. CUPT水瓶游戏这个压缩包在讲什么一次基于Visual C的益智项目还原如果你手里拿到一份CUPT.rar解压后看到的是“cupt水瓶”相关源码不要误以为这是什么物理竞赛的加密资料。这个项目本质是一个用 Visual C 写成的“倒水益智游戏”屏幕上有几只容量不同的瓶子初始水量各不相同你通过把一个瓶中的水倒入另一个瓶让某个瓶的水量精确达到目标值。玩法听起来简单但实现它恰好覆盖了 C 语法、状态搜索、Windows 消息循环和 GDI 绘制一整条链路比看视频教程更能让人搞懂 Windows 编程到底是怎么回事。反直觉的结论是这个游戏最难的不是画界面而是“倒水”动作的数学建模和编译环境。很多新手卡在cl.exe failed with exit status 2这类编译错误上跟游戏逻辑一点关系都没有。这篇文章就按“解压——配置——逻辑——绘图——排错——进阶”的顺序把 CUPT 水瓶游戏从能跑变能玩的过程完整拆给你。适合刚学完 C 想转 Windows 编程的人也适合在 Win11 里折腾老 Visual C 工程时天天踩坑的人。2. 从 CUPT.rar 到可运行 exe解压结构与项目配置2.1 压缩包内的典型文件构成先看有没有 .sln 再动手解压 CUPT.rar 后你要做的第一件事不是双击cupt.exe而是先看目录结构。一个标准 Visual C 工程一般长这样CUPT/ CUPT.sln CUPT.vcxproj cupt_game.cpp cupt_game.h resource.h stdafx.h Debug/ cupt_game.exe.sln是解决方案文件直接用 Visual Studio 双击打开.vcxproj是工程文件负责记录编译器和链接器参数resource.h定义菜单和图标资源stdafx.h是预编译头文件——在老工程里几乎必出现。如果你解压后只看到几个.cpp和.h而没有.sln也不用慌手动在 Visual Studio 里新建“空项目”把源码加进去就能编译前提是源码里没有依赖特定资源脚本的.rc文件。这里有个识别老工程的技巧看#include windows.h和#include stdafx.h的顺序。如果是stdafx.h在最前面说明作者用的是 VS2005 到 VS2013 那一代模板代码风格偏“远古”。不要用记事本去打开这些文件改编码后面避坑章会细讲。2.2 用 Visual Studio 打开 .sln 并设定启动项目拿到.sln后用 Visual Studio 2022或 2019打开。如果弹窗提示“需要升级工程”直接确认即可VS 会自动转换格式。这里最容易踩坑的是“平台工具集”不匹配老工程默认写的是v120对应 VS2013或者v140VS2015而新机器只有v143VS2022一编译就报MSB8020。正确的设置路径右键项目名 →“属性”→“常规”→“平台工具集”把它改成Visual Studio 2022 (v143)或你实际安装的版本。改完记得看一下顶部“配置管理器”选的是 Debug 还是 Release小游戏建议先 Debug x86。为什么是 x86因为 CUPT 这类老代码里如果用了int强转指针或者WndProc的LPARAM直接存坐标在 x64 下可能被当作 64 位指针处理轻则警告重则崩溃。用 x86 编译兼容性最好。转换完成后按键Ctrl Shift B试编译。如果通过直接按F5运行你应该能看到一个窗口弹出里面有若干只画成矩形的“水瓶”。如果编译失败往下看第 5 章。2.3 配置项目属性字符集、运行时库、附加依赖项三个属性必须手动检查按从高到低的优先级讲第一字符集。老工程默认使用“多字节字符集”而 VS2013 之后新建工程默认 Unicode。如果原代码里有char szBuf[256]、sprintf、MessageBox(hwnd, szBuf, 提示, MB_OK)这些写法Unicode 下直接报cannot convert argument 2 from const char[4] to LPCWSTR。解决方法是项目属性 →“常规”→“字符集”→“使用多字节字符集”。改完以后大部分字符串兼容问题都消失。如果你非要保留 Unicode那就得把所有char换成TCHAR工程量大不建议 CUPT 这种小项目折腾。第二运行时库。默认 Debug 是“多线程调试 DLL(/MDd)”Release 是“多线程 DLL(/MD)”这意味着运行 exe 时系统里要有对应版本的 C 运行时 DLL如VCRUNTIME140.dll。CUPT 这种单文件小游戏我强烈建议改成静态链接Debug 下选“多线程调试(/MTd)”Release 下选“多线程(/MT)”。改完之后生成的 exe 体积会大几十 KB但拷到任何一台 Windows 10/11 机器上都能直接跑不会出现“找不到 MSVCP140.dll”的尴尬。第三附加依赖项。如果代码里只用了user32.dll窗口、消息和gdi32.dll绘图默认链接就能过。如果代码出现了PlaySound、waveOut之类的音频 API需要手动加winmm.lib如果有联网功能则加ws2_32.lib。但 CUPT 水瓶游戏按惯例不会涉及这些除非作者加了音效。最好的检查方式是看源码里的#pragma comment(lib, xxx.lib)如果已经写了就不用动。3. 水瓶游戏的玩法逻辑状态表示与倒水判定3.1 把“水瓶”建模成两个整数游戏的核心数据结构极其简单就是两个整数容量和当前水量。用结构体表示struct Bottle { int cap; // 瓶子容量不可变 int water; // 当前水量0 water cap };整个游戏就是vectorBottle或者 C 风格Bottle bottles[3]。为什么用整数不用浮点数因为倒水游戏的目标水量和所有倒水动作的量都是整毫升或整单位整数运算没有浮点误差调试起来也直观。有的版本会加入“每瓶有不同容量”的关卡比如 5升、3升、8升目标量 4升这些都是整数。胜利条件不是“所有瓶子满”而是“任意一个瓶子的水量等于目标值”。这个判断要放在每次倒水之后但更稳妥的做法是在开局渲染第一帧前就检查一次防止初始状态就已经达标——那样玩家一进游戏就赢了会让人觉得程序有 bug。3.2 倒水动作的合法判定从一个瓶倒到另一个瓶倒水是整个游戏最核心的函数也是新手最容易写翻车的地方。直接给标准实现void Pour(Bottle from, Bottle to) { if (from to) return; // 不能自己倒给自己 int space to.cap - to.water; // 目标瓶剩余空间 if (space 0) return; // 目标已满无事发生 int move from.water space ? from.water : space; from.water - move; to.water move; }逻辑说明move取“源瓶水量”和“目标瓶剩余空间”的较小值。比如源瓶剩 3目标瓶还能装 5那就倒 3源瓶变 0如果源瓶剩 7目标瓶只能装 2那就倒 2源瓶剩 5。这一步必须用min的思想如果写成to.water from.water目标瓶水量立刻超过容量后续绘制的水位线会顶出瓶子游戏状态直接崩。参数说明里大多数人会忽略“自己倒自己”这个边界用户连续点击同一个瓶子两次程序应该把它当作选中而不是执行倒水。from to的指针比较就是防这个。另外还要考虑from.water 0的情况此时move 0两个瓶子都不变界面可以无需重绘。有的版本会允许“倒出”操作把水倒到地上那就是另一个函数Empty(Bottle b)把water置零逻辑更简单。3.3 胜负判定目标水量出现即成功先定义全局目标量int g_targetWater 4; bool IsWin(const vectorBottle bottles) { for (const auto b : bottles) if (b.water g_targetWater) return true; return false; }这个函数不需要判断“所有瓶子都满了”或者“游戏步数用尽”。倒水游戏通常没有步数上限所以只要目标量没出现游戏就继续。每执行一次Pour后调用一次IsWin如果返回 true就弹出一个MessageBox并重置关卡。一个容易被忽略的细节目标量可能大于某个瓶子的容量此时该瓶子永远不可能达成目标但不影响其他瓶子。合理的设计是在关卡生成时确保target max(cap_i)否则无解。这部分在自动生成关卡时尤其重要后面会讲。4. 在 Visual C 里把逻辑画成界面GDI 绘制与消息循环4.1 用 WM_PAINT 重绘瓶子和水位CUPT 水瓶游戏如果是标准 Windows 界面它的绘制方式几乎没有悬念用 Win32 GDI 在窗口客户区内画矩形。每个瓶子的外形可以画成一个带边框的圆角矩形水位则是瓶子里填充颜色的另一个矩形高度按water / cap比例计算。绘图代码写在WM_PAINT消息里case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); // 背景填充 HBRUSH bgBrush CreateSolidBrush(RGB(245, 245, 245)); FillRect(hdc, ps.rcPaint, bgBrush); DeleteObject(bgBrush); // 画每个瓶子 for (int i 0; i g_bottles.size(); i) { // 瓶身区域x 从 i*瓶间距开始高度 300 int x 50 i * 120; int yTop 80; int bottleW 60; int bottleH 300; // 先画水位矩形 int waterH (int)(bottleH * (g_bottles[i].water / (double)g_bottles[i].cap)); HBRUSH waterBrush CreateSolidBrush(RGB(30, 144, 255)); RECT waterRect {x, yTop bottleH - waterH, x bottleW, yTop bottleH}; FillRect(hdc, waterRect, waterBrush); DeleteObject(waterBrush); // 再画瓶子的边框 HBRUSH borderBrush (HBRUSH)GetStockObject(NULL_BRUSH); HGDIOBJ old SelectObject(hdc, borderBrush); Rectangle(hdc, x, yTop, x bottleW, yTop bottleH); SelectObject(hdc, old); } EndPaint(hwnd, ps); break; }逻辑说明waterH的计算用了浮点比例避免整数除法导致的水位线错位。FillRect填充水位颜色Rectangle画边框。注意先画水位再画边框否则边框会被水位盖住。CreateSolidBrush每次都要DeleteObject这是 GDI 用的血泪经验泄漏多了程序会变卡甚至崩。参数说明瓶子的x坐标用i * 120排列这是简单布局。如果瓶子数量多于 5 个你需要把间距缩小或者窗口加宽。yTop bottleH - waterH是水位矩形的顶边因为 GDI 的 y 轴向下为正所以水位越高矩形顶边越靠上。4.2 鼠标命中测试点哪个瓶子、倒向哪个瓶子交互设计建议使用两段式操作第一次点击选中一个“源瓶”第二次点击选中“目标瓶”程序自动执行Pour。为什么不做一个“拖拽倒水”因为 GDI 的命中测试和鼠标捕获实现拖拽复杂度高而且 CUPT 这类小游戏通常不会做拖拽。在WM_LBUTTONDOWN里处理case WM_LBUTTONDOWN: { int x LOWORD(lParam); int y HIWORD(lParam); // 坐标到我定义的瓶子矩形区域 for (int i 0; i g_bottles.size(); i) { int bx 50 i * 120; int by 80; int bw 60; int bh 300; if (x bx x bx bw y by y by bh) { if (g_selected -1) { g_selected i; // 第一次点击记录源瓶 InvalidateRect(hwnd, NULL, TRUE); // 重绘以高亮选中瓶 } else { Pour(g_bottles[g_selected], g_bottles[i]); // 第二次点击倒水 g_selected -1; if (IsWin(g_bottles)) { MessageBox(hwnd, TEXT(恭喜目标水量达到), TEXT(胜利), MB_OK); ResetGame(); // 重新生成关卡 } InvalidateRect(hwnd, NULL, TRUE); } break; } } break; }逻辑说明LOWORD(lParam)和HIWORD(lParam)取鼠标坐标。g_selected用来记忆当前选中的瓶子索引当-1时表示没有选中源瓶。第一次点击选中第二次点击执行倒水同时把g_selected恢复成-1。参数说明InvalidateRect的TRUE表示擦除背景后重绘否则可能留下残影。实际项目里更精细的做法是只重绘被点击的瓶子区域但程序员说不加速优化全窗口重绘对 60fps 的小游戏完全够用。4.3 最小可运行框架从 WinMain 到消息循环Visual C 写窗口程序的最小骨架是固定的CUPT 游戏的cupt_game.cpp大概率长这样#include windows.h #include cupt_game.h LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM); int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { WNDCLASS wc {0}; wc.lpfnWndProc WndProc; wc.hInstance hInstance; wc.lpszClassName TEXT(CUPTWindow); RegisterClass(wc); HWND hwnd CreateWindow(wc.lpszClassName, TEXT(CUPT 水瓶游戏), WS_OVERLAPPEDWINDOW | WS_VISIBLE, CW_USEDEFAULT, CW_USEDEFAULT, 640, 480, NULL, NULL, hInstance, NULL); MSG msg; while (GetMessage(msg, NULL, 0, 0)) { TranslateMessage(msg); DispatchMessage(msg); } return msg.wParam; }逻辑说明WNDCLASS注册窗口类CreateWindow创建并显示窗口GetMessage进入消息循环。WndProc负责处理WM_PAINT、WM_LBUTTONDOWN和WM_DESTROY。这个套路在所有 Win32 程序里几乎一模一样你会写一个就会写一万个。参数说明WS_OVERLAPPEDWINDOW是标准窗口样式WS_VISIBLE让窗口立即显示不需要手动ShowWindow。640x480是初始尺寸你可以在WM_SIZE里让瓶子布局自适应窗口大小但那属于进阶优化基础版固定大小完全够用。5. 避坑Visual C 编译失败的 5 个常见坑5.1 坑1cl.exe exit status 2——Python 环境抢占了编译器现象你在命令行里执行某条编译命令或者在安装 Python 包时看到完整报错error: command C:\Users\86181\AppData\Local\Programs\Common\Microsoft\Visual C for Python\9.0\vc\bin\amd64\cl.exe failed with exit status 2原因机器上装了“Visual C for Python 9.0”这个老掉牙的组件它的cl.exe是 VC9 的编译器。当某些构建工具扫描 PATH 里的编译器时先找到了它而 VC9 不支持新的 C 语法和 Windows SDK于是报错。这个问题在 Win11 上尤其常见因为新系统不会自动清理这些兼容层遗留。解决Windows 设置里搜索“应用程序与功能”卸载“Microsoft Visual C Compiler for Python 2.7/3.x”。这是玄学问题里的典型——看着像编译器坏了其实是软件冲突。如果你不想卸载可以手动编辑setuptools的配置文件指定 VS 编译器但最省心的还是卸载。CUPT 水瓶游戏工程里如果你只用 Visual Studio IDE 编译根本不会碰到这个 Python 组件但很多使用者电脑里已经装着它所以值得记录。5.2 坑2MSB8020 工具集版本不对现象用 VS2022 打开CUPT.sln后编译直接弹窗MSB8020: The build tools for Visual Studio 2013 (v120) cannot be found.然后工程拒绝编译。原因.vcxproj文件里写死PlatformToolsetv120/PlatformToolset而你的电脑只有 v143。VS 不会自动升级工具集除非你手动改。解决右键项目 →“属性”→“配置属性”→“常规”→“平台工具集”改成“Visual Studio 2022 (v143)”。如果弹窗建议你“启用用于 v120 的生成工具”可以点确认但那样还要额外装 VS2013 Build Tools没必要。改完以后正常编译代码基本不用改因为 Win32 API 接口三十年没变。5.3 坑3源文件编码导致中文乱码和 C4819 警告现象编译时疯狂刷警告C4819: The file contains a character that cannot be represented in the current code page运行后窗口标题和按钮文字全是乱码。原因老工程的.cpp通常保存为 GB2312/GBK 编码而 Win11 记事本默认保存为 UTF-8。你在记事本里改过源码或者从网络下载时被转换过编码VS 读 GBK 文件时按 UTF-8 解析中文就翻车了。解决用 Visual Studio 打开源文件选择“文件”→“高级保存选项”把编码改成“简体中文(GB2312)”并设置“行尾”为“Windows (CR-LF)”。如果你用的是 VS2015 以上可以在每一条带中文的字符串前加L前缀拼 Unicode或者直接下载官方清理编码的工具。最稳妥的办法是所有带中文的源码文件统一为 UTF-8 with BOM并在开头加#pragma execution_character_set(utf-8)这条指令只对 VS 生效GCC 会忽略不影响代码。5.4 坑4Release 运行提示缺少 MSVCP140.dll现象编译通过Debug 能跑切到 Release 编译后生成cupt_game.exe拷到别的电脑上双击报错“找不到 MSVCP140.dll”或“无法继续运行”。原因默认 Release 也采用 DLL 形式的 C 运行时/MD而目标机器没有安装“Visual C Redistributable”。自己机器能跑只是因为刚装完 VS运行时库都在系统里。解决项目属性 →“C/C”→“代码生成”→“运行时库”把 Release 的/MD改成/MT多线程静态库。改完重新编译exe 体积会从 40KB 涨到 200KB 左右但不需要任何 DLL 依赖。这个坑我早年做小工具翻车多次后来一律无论 Debug/Release 都静态链接省心。5.5 坑5Win11 控制台模式下闪退看不到输出现象CUPT 工程如果被编译成“控制台应用程序”子系统为控制台运行后窗口一闪而过什么都看不到如果它原本是 Win32 窗口程序则不会有此问题。原因控制台程序执行完main或WinMain就退出命令行窗口自动关闭。有些老代码用scanf等待输入但输入结束后立即返回也会立刻闪退。解决如果是窗口程序检查“链接器”→“系统”→“子系统”是否选“Windows (/SUBSYSTEM:WINDOWS)”。如果是控制台程序可以在main的结尾加一行std::cin.get();或者system(pause);。但更推荐直接改成窗口子系统因为水瓶游戏需要鼠标交互控制台里写鼠标处理荒谬得很。具体改法在WinMain的入口函数上链接器会把main自动映射到WinMainCRTStartup只要子系统正确就不会闪退。改了子系统后记得WM_DESTROY里写PostQuitMessage(0)否则窗口关了进程不退出。6. 进阶让 CUPT 水瓶游戏从能玩变好玩自动求解与随机关卡生成6.1 用 BFS 求解最短步数倒水游戏的状态空间很小假设有 3 个瓶子容量分别为 8、5、3初始是 8、0、0目标是 4、0、0那么总状态数最多是 9 × 6 × 4 216 种每个瓶子的水量从 0 到容量。用宽度优先搜索BFS可以求出从初始状态到任意目标状态的最短路径。BFS 的状态就是一组Bottle用queue存待扩展节点用set或哈希表存已访问状态防止死循环。关键剪枝是每次倒水时从一个瓶 A 倒向另一个瓶 B如果 B 已经满就跳过如果 A 为空也跳过。搜索到目标状态后沿着记录的前驱恢复操作序列。6.2 在 VC 中嵌入求解器你可以把求解器做成一个独立函数放在solver.h里。代码逻辑如下bool Solve(const vectorBottle start, int target, vectorstring steps) { using State vectorint; queueState q; mapState, State parent; mapState, string action; State init; for (auto b : start) init.push_back(b.water); q.push(init); parent[init] init; while (!q.empty()) { State s q.front(); q.pop(); for (int i 0; i s.size(); i) { if (s[i] target) { // 回溯路径 while (parent[s] ! s) { steps.push_back(action[s]); s parent[s]; } reverse(steps.begin(), steps.end()); return true; } } for (int i 0; i s.size(); i) { for (int j 0; j s.size(); j) { if (i j) continue; State next s; int move min(next[i], start[j].cap - next[j]); if (move 0) continue; next[i] - move; next[j] move; if (parent.count(next) 0) { parent[next] s; action[next] 倒水 to_string(i1) - to_string(j1); q.push(next); } } } } return false; }逻辑说明parent记录每个状态是从哪里来的action记录转移动作。利用map对vectorint的比较特性可以天然判断状态是否已访问。回溯路径时要注意parent的起点是自己所以parent[s] ! s是正确的退出条件。steps里保存的是类似“倒水 1 - 2”的文本玩家照着做就能过关。参数说明start[j].cap - next[j]算目标瓶剩余空间min取可倒水量。这个求解器虽然没做任何优化但状态空间小瞬间就能出结果。如果你要生成关卡的“最少步数”只需要把 BFS 的层数记录下来第一次到达目标状态时步数就是最短因为 BFS 天然按层扩展。6.3 验证自动生成关卡的可解性随机生成关卡看起来简单指定瓶子容量和初始水量再随机选一个目标水量但实际上有概率生成无解关卡——比如初始水量组合是 [0, 0, 0]目标设置了 4永远达不到。所以在每次随机生成后一定要先调用Solve函数验证。具体做法把Solve返回 false 的关卡丢弃重新生成。如果连续 100 次都无解说明初始水量范围设置有问题常见的错误是目标水量大于最大瓶容量或者所有瓶子的容量之间不存在合法的公约数组合。倒水游戏的数学基础是“每次倒水要么倒空一个瓶子要么装满一个瓶子中间量是整数”所以能达到的目标必须是初始水量和容量通过最大公约数组合出来的值。比如容量是 8 和 5初始 0,0你只能得到 0、5、8、3、6、1、7、2不可能得到 4。验证可解性比验证步数重要得多。我的习惯是在生成关卡时直接把目标水量也作为随机变量先用 BFS 跑一遍通了就保留不通就重换。这样一来CUPT 水瓶游戏就从一个“只能手打关卡”的演示程序变成了可以无限换关的完整游戏。最后再说一句Visual Studio Code 也能看代码但编译调试 Windows 窗口程序还是老老实实用 Visual Studio两者的 “Visual C” 生态根本不是一回事这种坑我踩过一次就不再回头。希望今日这份手记能帮你把 CUPT 水瓶项目一次编译通过。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?