☰
基于VC6与MFC的贪吃蛇:GDI双缓冲、消息循环与二次开发
2026/11/2 13:26:20 网站建设 项目流程

简介:该资源是一份适合C++初学者与游戏开发入门者的贪吃蛇项目源码包,基于VC6.0与MFC实现,重点演示如何用面向对象的“双类”设计拆分蛇体与链表节点,并通过单链表动态增删节点表示蛇身增长,同时涵盖键盘消息响应、界面绘制、定时器调速、游戏状态管理与异常退出处理等完整流程,可作为学习Windows图形界面程序与链表数据结构的实践案例。压缩包共58个文件,约8.98MB,包含5个cpp源文件与6个h头文件承载核心逻辑,dsp、dsw工程文件与rc资源脚本、bmp位图、ico图标用于构建与界面,另有exe可执行程序及obj、sbr、pdb等编译调试中间文件,便于直接运行或断点调试。目前已有180人学习,适合对照源码理解消息循环、定时器与链表操作的落地写法。

1. 一个能跑通的 VC6 小工程,比十篇语法教程管用

很多人学完 C++ 的类与继承,卡在「写不出一个有画面的东西」。网上翻到的贪吃蛇源码,解压出来是一堆 .cpp、.h、.rc、.dsp,双击 .dsw 弹一句「此项目由早期版本创建」,然后就没有然后了。这份资源针对的正是这个断点:它是一个在 VC6.0 环境下用 MFC + GDI 写的增强版贪吃蛇,除了基础的前进吃食物,还做了双缓冲防闪烁、速度分级、障碍物和最高分本地保存。它不适合当商业项目底座,却很适合拿来补三块短板——Windows 消息循环、GDI 绘图、以及一个小游戏该有的状态机。想练手 C++ 图形界面的人,把工程编译出来,再动手改两个玩法,比看十篇语法文章实在得多。

2. 架构解剖:从 CWinApp 到一帧画面,游戏是怎么被驱动的

小游戏最容易写成一坨,所有逻辑塞在 OnPaint 里,改一个玩法要动三个函数。这份工程的骨架相对干净,它把「算」和「画」分开了:CGameCore 只关心格子坐标和状态,一行 GDI 都不碰;对话框类负责接消息、调逻辑、把结果画出来。这种分法在 VC6 时代不算时髦,但后面加障碍物、加 AI 的时候,会非常省心。

2.1 三个类撑起骨架,各管一段

MFC AppWizard 生成工程时有两个选择:单文档(SDI)和对话框(Dialog based)。小游戏一般选对话框,理由是省掉文档/视图那一层,消息映射也直白。工程里实际起作用的就三个类:

文件类职责
Snake.cpp / Snake.hCSnakeApp : CWinApp程序入口,InitInstance 里创建主对话框
SnakeDlg.cpp / SnakeDlg.hCSnakeDlg : CDialog消息映射、定时器、键盘、调用绘制
GameCore.cpp / GameCore.hCGameCore蛇身容器、食物、碰撞、状态机,无 UI 依赖
Render.cpp / Render.h一组自由函数把 CGameCore 的数据翻译成 GDI 调用
config.ini—最高分、初始速度、格子尺寸

GameCore 的头文件是整个工程的契约,先看它:

// GameCore.h —— 只做逻辑,不碰 HWND,也不碰 CDC #pragma once #include <afxtempl.h> // VC6 自带的 STL 有坑,容器优先用 MFC 的 enum Dir { DIR_UP = 0, DIR_RIGHT, DIR_DOWN, DIR_LEFT }; enum GameState { GS_READY, GS_RUNNING, GS_PAUSE, GS_OVER }; class CGameCore { public: CGameCore(); void Reset(int nCols, int nRows); // 重开一局 void SetDir(Dir d); // 只入队,不立刻生效 bool Step(); // 前进一格,false = 死亡 void TogglePause(); const CArray<CPoint, CPoint&>& Body() const { return m_body; } CPoint Food() const { return m_food; } int Score() const { return m_score; } int Level() const { return m_level; } GameState State() const { return m_state; } private: bool HitWall(const CPoint& p) const; bool HitSelf(const CPoint& p) const; void SpawnFood(); CArray<CPoint, CPoint&> m_body; // 下标 0 = 蛇头 CArray<Dir, Dir> m_inputQ; // 待消费的方向输入 CPoint m_food; Dir m_dir; int m_cols, m_rows; int m_score, m_level; GameState m_state; };

CArray<CPoint, CPoint&>这个模板参数写法是 MFC 的老规矩,第二个参数是引用类型,用来避免拷贝。VC6 编译CArray<CPoint, CPoint>也能过,但取值时会多一次构造。Body()返回 const 引用,Render 那边直接遍历,不产生临时数组。

m_body[0]固定是蛇头,这个约定要贯穿全文。很多新手把尾巴当下标 0,结果画出来的蛇和逻辑对不上,查半天。

2.2 时间驱动:SetTimer 管节奏,timeGetTime 管精度

游戏动起来靠SetTimer。但WM_TIMER是低优先级消息,Windows 只在消息队列空了才投递,而且同类定时器消息会被合并——你设 20ms,实际可能 40ms 才来一次,机器一忙就更飘。血泪经验是:别拿 SetTimer 的间隔当游戏速度,把它当成「心跳检查」,真正的节拍用timeGetTime()算。

// 消息映射里加:ON_WM_TIMER() #define TIMER_TICK 1 BOOL CSnakeDlg::OnInitDialog() { CDialog::OnInitDialog(); m_core.Reset(30, 24); // 30 列 x 24 行 m_lastTick = timeGetTime(); // 需要 #include <mmsystem.h> 并链接 winmm.lib SetTimer(TIMER_TICK, 15, NULL); // 15ms 心跳,只负责「来看一眼该不该走」 return TRUE; } void CSnakeDlg::OnTimer(UINT nIDEvent) { if (nIDEvent != TIMER_TICK) { CDialog::OnTimer(nIDEvent); return; } DWORD now = timeGetTime(); DWORD past = now - m_lastTick; int interval = 200 - m_core.Level() * 20; // 200ms 起步,每档减 20ms if (interval < 60) interval = 60; // 下限,再快人反应不过来 if ((int)past >= interval) { m_lastTick = now; // 用 now 而不是 now+interval,避免误差累积 if (m_core.State() == GS_RUNNING) { if (!m_core.Step()) GameOver(); // 死亡:停表、写最高分、提示重开 Invalidate(FALSE); // FALSE = 不擦背景,配合双缓冲 } } CDialog::OnTimer(nIDEvent); }

几个参数值得说清楚。SetTimer的 15ms 是心跳粒度,越小越跟手,但太小会白占 CPU,15 到 30 之间比较合适。interval是逻辑帧间隔,用m_level做线性递减,60ms 是硬下限——低于这个值,键盘输入开始跟不上,玩起来像在自虐。m_lastTick = now而不是now + interval,是为了让掉帧不会滚雪球;如果写成累加,机器卡一下之后会连续补跑好几帧,蛇直接瞬移撞墙。

提示:timeGetTime()返回的是系统启动后的毫秒数,32 位会回绕(约 49 天),但用DWORD做减法天然处理回绕,不用额外判断。

2.3 数据与渲染分离:双缓冲画布怎么搭

GDI 直接往窗口 DC 上画,每画一个矩形就刷新一次,蛇一长就满屏闪烁。标准解法是内存 DC + 位图,一次性 BitBlt 过去。

void CSnakeDlg::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(&rc); CDC memDC; CBitmap bmp; memDC.CreateCompatibleDC(&dc); bmp.CreateCompatibleBitmap(&dc, rc.Width(), rc.Height()); // 必须在 SelectObject 之前 CBitmap* pOld = memDC.SelectObject(&bmp); memDC.FillSolidRect(&rc, RGB(24, 24, 28)); DrawGrid(memDC); // 网格 DrawFood(memDC, m_core.Food()); DrawSnake(memDC, m_core.Body()); DrawHud(memDC, m_core.Score(), m_core.Level()); dc.BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); } BOOL CSnakeDlg::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 背景由 OnPaint 全权负责,别再擦一遍 }

CreateCompatibleBitmap那一行的顺序是经典翻车点:如果先SelectObject再创建位图,内存 DC 里默认只有一张 1x1 的单色位图,你选进去的是一张还没初始化的空图,结果就是全黑或者全白。这个坑我在不同项目里见过至少三次。

OnEraseBkgnd返回 TRUE 同样关键。MFC 默认会在WM_ERASEBKGND里刷一遍背景色,这一刷发生在OnPaint之前,画面就会「闪一下白再出内容」。加上Invalidate(FALSE)(参数是bErase),擦背景这一步就彻底省了。

绘制函数本身没什么技巧,FillSolidRect画格子,蛇头用亮一点的颜色区分,食物画个圆。格子尺寸(比如 20px)建议放 config.ini,改分辨率时不用重编译。

3. 跑起来:从双击 .dsw 到看见蛇动,中间有几步

拿到源码第一件事不是读代码,是让它先跑起来。跑不起来,读再多也是纸上谈兵。

3.1 工程配置与依赖检查

VC6 的工程文件是.dsp(项目)和.dsw(工作区),用文本编辑器能直接看。打开之前先确认三处:

第一,Project → Settings → Link里有没有winmm.lib。上面用了timeGetTime,漏掉这个库会报unresolved external symbol __imp__timeGetTime@0。如果不想改工程设置,也可以在stdafx.h里加一行#pragma comment(lib, "winmm.lib"),更省事。

第二,Project → Settings → C/C++ → Code Generation里的运行库要一致。Debug 配Debug Multithreaded DLL,Release 配Multithreaded DLL,MFC 工程别手动改成静态库,否则链接期一堆重定义。

第三,字符集。VC6 默认多字节(MBCS),工程里的字符串"贪吃蛇"直接是 GBK 字节,不做任何转换。这份代码是按 MBCS 写的,如果后面迁到新 IDE,必须显式保留多字节字符集,否则CString到char*的隐式转换全断。

3.2 编译、链接、调试,F5 之后该看到什么

VC6 的 IDE 编译就是Build → Build Snake.exe,或者 F7。想看详细日志,可以在命令行里调msdev:

:: 在 cmd 里执行,路径按本机 VC6 安装位置调整 "C:\Program Files\Microsoft Visual Studio\Common\MSDev98\Bin\msdev.exe" ^ Snake.dsp /MAKE "Snake - Win32 Release" /REBUILD

/MAKE后面的字符串必须和Build → Set Active Configuration里显示的名字一字不差,包括中间的连字符和空格,写错会提示The following project was not found。/REBUILD是强制全量重编,改过stdafx.h之后必须用它,否则预编译头不更新,改了等于没改。

正常情况下 F5 之后应该看到:一个固定大小的窗口,深色背景,中间一条三格长的蛇,角落有食物,左上角显示分数。按方向键蛇开始移动,吃到食物变长、加分,撞墙或撞自己弹出结算。

如果窗口出来了但蛇不动,先看三件事:OnInitDialog里SetTimer有没有被调用(打断点);m_core.State()是不是还停在GS_READY;OnTimer的nIDEvent判断有没有写反。

3.3 用新版 IDE 打开的老工程迁移路径

VC6 的.dsw在 VS2015 之后基本打不开,会走转换向导。转换本身不难,难在转完之后冒出来的一堆错误,按这个顺序处理效率最高:

先把#include <afxtempl.h>、<mmsystem.h>这类老式头文件统一挪进pch.h(或stdafx.h),新版 IDE 默认预编译头只吃pch.h。然后装「C++ MFC 多字节字符集支持」这个可选组件——VS2015 之后它不再默认安装,缺了会报MSB8041: MFC libraries are required。最后处理for循环变量作用域:VC6 里for (int i = 0; ...)的i会泄漏到循环外,新版标准不允许,编译器会直接报错,逐个把变量提到循环外声明即可。

还有一处隐蔽的:VC6 的CArray::InsertAt(int, ARG_TYPE, int)三参数版本和新版签名一致,但RemoveAll之后的容量不会释放。如果代码里频繁Reset,建议改成m_body.SetSize(0)加手动FreeExtra,内存曲线会好看很多。

4. 核心模块拆解:蛇怎么走、食物怎么长、碰撞怎么判

这一章是整份代码里最值得抄的部分。三个函数加起来不到一百行,但每一步都有讲究。

4.1 蛇身容器与移动算法:一次插入加一次删除

蛇的移动不是「整体平移」,而是「头插一格、尾删一格」。吃到食物时不删尾,长度自然加一。这个思路比逐个格子赋值快得多,也不会出现覆盖问题。

bool CGameCore::Step() { // 1. 从输入队列取一个方向,且禁止 180 度掉头 if (m_inputQ.GetSize() > 0) { Dir d = m_inputQ[0]; m_inputQ.RemoveAt(0); if ((d + 2) % 4 != m_dir) // UP(0)<->DOWN(2)、LEFT(3)<->RIGHT(1) m_dir = d; } // 2. 算新蛇头 CPoint head = m_body[0]; switch (m_dir) { case DIR_UP: head.y -= 1; break; case DIR_DOWN: head.y += 1; break; case DIR_LEFT: head.x -= 1; break; case DIR_RIGHT: head.x += 1; break; } // 3. 撞墙 / 撞自己 if (HitWall(head) || HitSelf(head)) { m_state = GS_OVER; return false; } // 4. 新头插到最前 m_body.InsertAt(0, head); // 5. 吃到食物保留尾巴(等于变长),否则砍尾 if (head == m_food) { m_score += 10; if (m_score % 50 == 0) m_level++; // 每 50 分提一档 SpawnFood(); } else { m_body.RemoveAt(m_body.GetSize() - 1); } return true; }

(d + 2) % 4这个判断依赖枚举顺序UP=0, RIGHT=1, DOWN=2, LEFT=3。上下的差是 2,左右的差也是 2,所以加 2 取模就能判断反向。如果将来改枚举顺序,这行会静默失效,记得加注释。

InsertAt(0, ...)是 O(n) 的,因为要把后面所有元素后移。蛇长撑死几百节,这点开销可以忽略;真要优化,可以用环形缓冲,头尾各一个索引,插入和删除都是 O(1)。这是二次开发里比较有价值的改造点,后面会讲。

HitSelf的实现有个容易忽略的细节:如果这一帧没吃到食物,尾巴马上就会移走,那么蛇头进入「当前尾巴所在格」其实是合法的。严格的写法是:

bool CGameCore::HitSelf(const CPoint& p) const { // 尾巴这一帧会让位,所以判断范围到 size-1 为止 int n = m_body.GetSize() - (p == m_food ? 0 : 1); for (int i = 0; i < n; ++i) if (m_body[i] == p) return true; return false; }

不过 SpawnFood 里判断「这个格子是不是空着」时,尾巴是实打实占着的,得用完整长度。所以代码里最好准备两个函数,一个HitSelfStrict给食物用,一个HitSelf给移动用,别图省事合成一个。

4.2 食物落点与碰撞检测:rand() 不能直接拿来用

最直觉的写法是CPoint(rand() % cols, rand() % rows),然后判断在不在蛇身上。蛇短的时候没问题,蛇长到占满半个屏幕,随机命中的概率暴跌,极端情况下会连续几百次撞在蛇身上,看起来就像卡死。

void CGameCore::SpawnFood() { int total = m_cols * m_rows; if (m_body.GetSize() >= total) { m_state = GS_OVER; return; } // 满屏即通关 for (int n = 0; n < 200; ++n) // 快速路径:蛇短时几十次内必中 { CPoint p(rand() % m_cols, rand() % m_rows); if (!HitSelfStrict(p)) { m_food = p; return; } } // 兜底:蛇很长时改成线性扫,保证一定能出结果 for (int y = 0; y < m_rows; ++y) for (int x = 0; x < m_cols; ++x) if (!HitSelfStrict(CPoint(x, y))) { m_food = CPoint(x, y); return; } }

200 次这个上限是经验值:格子总数 720,蛇长 300 时命中率约 58%,200 次全不中的概率是 10 的负 70 次方量级,实际不会走到兜底分支。真正的价值在「一定能出结果」这个保证上,避免极端局面下界面假死。

还有一个必须做但经常被忘的事:srand。VC6 里不调srand的话,rand()每次启动的序列完全一样,玩家会发现每局第一个食物都在同一个位置。常见做法是在OnInitDialog里srand(timeGetTime()),用毫秒数而不是time(NULL),因为后者一秒内只变一次,连开两局种子一样。

碰撞检测分两种语义:撞墙和撞自己。这份代码默认是「撞墙即死」,但增强版通常提供一个「穿墙」开关——蛇头越界后从对面出来:

CPoint CGameCore::Wrap(const CPoint& p) const { CPoint q = p; if (q.x < 0) q.x = m_cols - 1; if (q.x >= m_cols) q.x = 0; if (q.y < 0) q.y = m_rows - 1; if (q.y >= m_rows) q.y = 0; return q; }

在Step里判断HitWall之前先调Wrap,就变成了穿墙模式。开关状态扔进 config.ini,改完重进游戏生效。

4.3 输入缓冲:为什么你的蛇会自己撞自己

这是新手问得最多的一个问题:明明按的是「先右再上」,蛇却往左拐,然后一头撞死。

原因在消息处理上。键盘的WM_KEYDOWN来得比定时器密集得多,两次按键间隔可能只有 20ms,而逻辑帧是 200ms 一帧。如果OnKeyDown里直接改m_dir,这一帧内连按「右、上、左」,最终m_dir是左,而蛇头原本朝右——左是反向,按规则应该拒绝,但代码只检查了最后一次,前两次的方向被吞掉了,玩家看到的就是「我明明按了上,它却直着走」。

正确做法是入队,每帧消费一个:

void CGameCore::SetDir(Dir d) { if (m_inputQ.GetSize() >= 3) return; // 队列封顶,防止狂按堆积 // 和队列里最后一个方向比,过滤连续重复和反向 Dir base = (m_inputQ.GetSize() > 0) ? m_inputQ[m_inputQ.GetSize() - 1] : m_dir; if (d == base || (d + 2) % 4 == base) return; m_inputQ.Add(d); }

队列封顶 3 个,是因为人的手速再快,一帧 200ms 内也按不出四个有效转向。封顶之后,Step里每帧只取一个,方向序列就完整保留了,玩家按「右上左」,蛇就真的先右、再上、再左。

注意:SetDir里判断反向要用队列末尾的方向,不是m_dir。用m_dir的话,「上、右、下」这种合法序列里,下和上反向会被误杀。

对话框程序还有一个额外麻烦:方向键默认被 CDialog 拿去在控件之间导航,OnKeyDown根本收不到。解决办法是重写OnGetDlgCode:

UINT CSnakeDlg::OnGetDlgCode() { return DLGC_WANTARROWS | DLGC_WANTALLKEYS; // 让方向键和回车都进 OnKeyDown }

少了这行,你会以为是键盘处理写错了,实际是消息压根没到你手上。

5. 避坑与排查:VC6 上跑小游戏最容易翻车的五件事

5.1 画面一直闪,双缓冲像没生效

现象:加了内存 DC,画面还是每隔几帧闪一下白。

原因有两个。一是Invalidate(TRUE)或Invalidate()不传参,默认bErase = TRUE,触发WM_ERASEBKGND先刷一遍背景;二是OnEraseBkgnd没有重写,MFC 默认实现会老老实实填背景色。

解决:调用处统一写Invalidate(FALSE),同时重写OnEraseBkgnd直接return TRUE。两处缺一不可,只改一处还是闪。

5.2 方向键完全没反应,鼠标点一下又能动

现象:刚打开游戏按方向键蛇不动,用鼠标点一下窗口内部才行。

原因:对话框打开时焦点在第一个可聚焦的控件上(通常是「开始」按钮),方向键被按钮当成控件导航吃掉了。

解决:OnInitDialog里把焦点移到对话框本身,或者在对话框上不放大任何按钮,改用键盘空格开始。再保险一点,加上前面提到的OnGetDlgCode返回DLGC_WANTARROWS。

5.3 Debug 正常,Release 里蛇跑得像开了加速

现象:同一份代码,Debug 版速度正常,Release 版快三四倍。

原因:逻辑帧靠Sleep或空循环凑时间,Release 把空循环优化掉了;或者某个成员变量没在构造函数里初始化,Debug 下被填成 0xCC 恰好能跑,Release 下是随机值。

解决:节拍统一走timeGetTime(),别用Sleep控速。构造函数里把m_dir、m_score、m_level、m_state全部显式初始化。再把警告级别开到/W4,VC6 对未初始化变量会给 C4700,别嫌吵。

5.4 换台机器编译报一堆 C2065、C2668

现象:在自己电脑上好好的工程,拷给别人一编译,几十个「未声明的标识符」。

原因:VC6 的for (int i = 0; ...)里i的作用域泄漏到循环外,后面代码可能无意中用了它;换到新版编译器,作用域规则收紧,那些隐式依赖全部断掉。另外 VC6 自带的 STL 实现有已知缺陷,<vector>的部分模板在特定写法下会编不过。

解决:迁移前先把所有循环变量提到循环外声明,容器统一换成CArray/CList。这一步做完,剩下的报错基本就只剩字符集和预编译头了。

5.5 最高分重启就丢,或者读出来是乱码

现象:玩完一局显示最高分,关掉重开变成 0;或者读出来是一串问号。

原因:用CStdioFile以文本模式写文件,换行被转成\r\n,再读的时候ReadString把\r也读进来了,_ttoi转换失败返回 0。另外 VC6 默认 MBCS,如果文件被记事本存成 UTF-8,读进来直接是乱码。

解决:别自己拼文件格式,直接用WritePrivateProfileString/GetPrivateProfileInt读写 ini,系统 API 帮你处理编码和格式:

// 存档 CString s; s.Format(_T("%d"), m_core.Score()); ::WritePrivateProfileString(_T("game"), _T("best"), s, _T(".\\config.ini")); // 读档 int best = ::GetPrivateProfileInt(_T("game"), _T("best"), 0, _T(".\\config.ini"));

第三个参数是默认值,文件不存在或键不存在时返回 0,不用额外判断文件句柄。

6. 二次开发:把这份 Demo 改成你自己的版本

真正动手改之前,先想清楚改哪一层。这份代码的分层已经把「逻辑」和「表现」隔开了,所以扩展点也自然分成三类,风险从低到高排列如下。

改动点涉及文件风险建议顺序
换配色、格子尺寸、字体Render.cpp、config.ini极低先做这个熟悉工程
加障碍物、多关卡GameCore.cpp、关卡数据中第二次做
穿墙 / 加速 / 减速道具GameCore.cpp中和上一个一起做
蛇身容器换环形缓冲GameCore.cpp较高有性能诉求再做
自动寻路 AI、录像回放新增 AIController.cpp高最后做

先从障碍物说起,因为它最能体现「逻辑和渲染分离」的好处。障碍物本质是一组不可通行的格子,加到CGameCore里就是一个CArray<CPoint, CPoint&>,在Step的碰撞判断里多查一次,在SpawnFood里多排除一次:

// 关卡数据可以硬编码,也可以从 ini 读 void CGameCore::LoadLevel(int nLevel) { m_walls.RemoveAll(); if (nLevel == 2) { for (int x = 5; x < m_cols - 5; ++x) { m_walls.Add(CPoint(x, m_rows / 2)); // 横一道墙,留出两端 } } // 注意:加载完要检查蛇的初始位置别压在墙上 }

加完之后必须验证两件事:一是Reset时蛇的出生点不能落在墙里,二是SpawnFood的兜底线性扫描也要跳过墙格,否则食物会刷在墙里,永远吃不到,游戏卡死。这两个点我第一次写的时候全踩了,一个导致开局即死,一个导致满屏只剩几个空格时找不到食物。

第二个值得做的是把CArray换成环形缓冲。蛇的移动只有两个操作:头部插入、尾部删除,正好是队列的两端。开一个固定大小的CPoint数组,维护head和tail两个索引,加一个count,插入和删除都是 O(1),还能顺便解决「蛇太长时InsertAt(0)掉帧」的问题。改的时候把Body()的返回类型一起改掉,Render 那边遍历方式也得跟着变,这是唯一需要动两处的地方。

改完之后怎么确认没改坏?我一般用三个土办法。第一,在Step里加一个帧计数器,每帧自增,游戏结束时打印总帧数,和「存活秒数 × 每秒帧数」对得上,说明没有掉帧或跳帧。第二,把m_body的坐标每帧 dump 到日志,检查有没有重复坐标——出现重复就说明插入删除逻辑有 bug,画面看不出来但迟早出事。第三,临时把interval改成 1000ms,慢放到一秒一帧,肉眼盯着蛇走完整一圈,很多边界问题在这个速度下无所遁形。

还有一个我吃过亏的地方:改完玩法先别急着调渲染。逻辑层的 bug 和渲染层的 bug 表现很像,都是「画面不对」,混在一起查会浪费大量时间。正确姿势是先把Step的返回值、蛇身坐标、分数用OutputDebugString打出来,确认逻辑是对的,再回头看画面。

从那以后我每次改这类小工程的玩法,都强制先跑一遍「日志 + 慢放」双验证,再去看窗口里的效果。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询