简介:这套C#小游戏合集收录了飞机大战、俄罗斯方块、贪吃蛇、拼图游戏、连连看、五子棋等七个经典项目,代码注释翔实,专为刚入门C#的开发者打造。每个项目均为可直接运行的完整工程,适合通过阅读源码理解游戏循环、事件处理、界面绘制与碰撞检测等基础概念。不同玩法还覆盖了二维数组消行、随机生成与匹配消除、棋盘胜负判断等常见算法场景,便于初学者结合实际代码逐一掌握。资源共1286个文件,以cs源代码、xml配置、png/jpg图片素材、wav/mp3音效以及dll依赖库为主,整体仅74.9MB,下载与解压都很轻量。项目按游戏单独分目录存放,查找某个玩法或横向对比不同实现十分方便。目前已有1284人学习使用,无论是巩固课堂知识、完成课程设计,还是提升WinForms开发能力,这套多玩法合集都能提供清晰直观的参照。
1. C# 小游戏项目合集:注释详细、拆解清晰的入门源码包
第一次把这套小游戏源码下下来时,我的第一反应是“玩法有点眼熟”——飞机大战、俄罗斯方块、贪吃蛇、拼图游戏、连连看、五子棋,全是入门项目里的经典面孔。可真正把几个核心文件翻完之后,我反而觉得这种“没追求”正是它最大的优点:代码不难,但注释密度足够高,适合刚学完 C# 基础语法、想看看真实项目怎么写的人。
这套资源真正的价值不在游戏本身,而在它把 C# 入门阶段最常碰到的知识点都串起来了:类怎么拆分、事件怎么绑定、二维数组怎么当棋盘用、Timer 怎么驱动画面刷新、键盘输入怎么处理、坐标怎么换算。你不需要一次性读懂全部七个项目的代码,只要挑一个自己玩得最熟的,比如贪吃蛇或者俄罗斯方块,从入口类开始往下追,就能把“一整个游戏是怎么组织起来的”看清楚。
如果你只是需要一份能编译通过、交作业用的现成代码,它也能用;但那样会浪费掉最有价值的中文注释。想靠它真正入门,我建议按后面的顺序读一遍工程结构,再动手改几个难度参数,最后把坑踩一遍。下面开始。
2. 先看工程结构:解决方案、Timer 主循环与双缓冲绘制
解压源码之后,你看到的通常不是一个孤零零的.cs文件,而是一整个解决方案里的多个项目。很多人拿到手直接双击某个Program.cs,编译报错就开始怀疑人生。其实小游戏项目大都是 Windows 窗体程序,入口藏在窗体类里,先看清解决方案结构,比急着看代码要重要得多。
2.1 解决方案和项目组织:先看清每个工程各自负责什么
每个小游戏在解决方案里通常对应一个独立工程,有各自的.csproj文件,相互之间引用很少。这种组织方式对初学者很友好:你想研究俄罗斯方块,就只看俄罗斯 square 项目的代码,不会被其他游戏的逻辑干扰。在我经手的类似项目里,文件命名一般遵循游戏名 + 窗体 + 实体类的规律,比如MainForm.cs负责界面和键盘事件,GameLogic.cs负责棋盘和规则,Block.cs或Enemy.cs负责某个游戏对象。
拿到一个不熟悉的解决方案,我习惯先跑一遍下面的命令,把项目的分布列出来,再决定从哪个入口进。
# 假设解决方案文件是 CSharpGames.sln,在它所在目录执行 Get-ChildItem -Recurse -Filter *.csproj | Select-Object DirectoryName, Name这段命令会递归找出目录下所有 C# 工程文件,输出的结果里能看到每个游戏工程的路径。逻辑很简单:.csproj就是一个 C# 项目的身份证,里面有目标框架版本、引用的类库、编译入口等信息。你打开某个.csproj文件时,只需要关注<TargetFramework>这一行,它会告诉你项目是跑在 .NET Framework 还是 .NET 6/8/9 上,这决定了你本机装哪个 SDK 才能编译运行。
有了项目清单以后,再从解决方案.sln文件里看启动项目。右键解决方案选择“设置启动项目”,或者看.sln里第一个Project(...)条目,通常就是作者预留的入口。如果你只想体验某一个游戏,把对应项目设为启动项目即可,不必每次都从主菜单进入。
2.2 游戏主循环:Timer 的间隔决定难度和流畅度
读这些游戏源码时,你会发现核心循环很少用while (true),而是用一个Timer控件。这不是因为作者偷懒,而是因为在 WinForms 里直接在死循环里更新控件,界面会假死、窗口拖不动、连关闭按钮都点不了。用Timer的好处是它在 UI 线程上按固定间隔触发事件,既能保证画面刷新,又不阻塞消息循环。
我拆解这类项目时见过的标准写法是这样:
private Timer gameTimer; private void InitGameLoop() { gameTimer = new Timer(); gameTimer.Interval = 40; // 40ms触发一次,约25FPS gameTimer.Tick += OnTick; // 每次触发都做“逻辑更新 + 重绘” gameTimer.Start(); } private void OnTick(object sender, EventArgs e) { if (!isRunning) return; UpdateGameLogic(); // 第一步:更新方块位置、蛇身、子弹坐标 Invalidate(); // 第二步:让窗体重绘,触发Paint事件 }这里要重点说两个参数。Interval的单位是毫秒,40ms 对应每秒 25 次刷新,对一个入门小游戏来说手感已经在可接受范围内;如果改成 20ms,画面更丝滑,但游戏难度会明显提高,因为方块下落或者蛇前进的速度快了一倍。Invalidate()是向 Windows 发送重绘请求,真正的绘制代码在窗体的Paint事件里执行,而不是在这个方法里直接画。刚学 C# 的人最容易犯的错误是在OnTick里调用某个控件的Text属性或者Location属性去改 UI,那样往往没用,因为最终渲染还是被Paint接管。
这套“Timer 驱动 → 逻辑更新 → 重绘”的模式,和网页游戏里的 requestAnimationFrame、游戏引擎里的 Update 循环是同一个思想。你以后写 C# 上位机软件,或者做自定义控件动画,用的也还是这套框架。理解这一层,就能看懂这些游戏项目的骨架。
2.3 双缓冲与 Paint 事件:不设置闪烁就特别明显
小游戏要经常重绘,而 WinForms 默认的绘制方式有一个毛病:刷新频率一高,画面会闪。这是因为系统先擦掉旧画面,再画新画面,擦和画之间有一段空白时间,肉眼看到的就是闪烁。
解决闪屏的通用做法是 Open 双缓冲,代码通常放在窗体构造函数或Load事件里:
// 解决画面闪烁:先画到内存缓冲区,再一次提交给屏幕 SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true);这三项样式分别是:允许在WM_PAINT消息里直接绘制、由应用程序自己负责绘制、以及使用双缓冲。给游戏画布设置双缓冲之后,绘图过程是先画到一块内存位图上,完成后一次性贴到屏幕上,用户的肉眼就看不到“擦除”过程了。如果你在源码注释里看到“防止闪烁”这几个字,配的通常就是这段代码。
这里还要注意Paint事件里Graphics对象的释放习惯。很多人直接在Paint里new Graphics或者从控件拿一个Graphics就画完不管,内存句柄会累积。常见做法是让系统传进来的e.Graphics配合 IDisposable 使用,或者把绘图逻辑都包在using块里。初中级项目注释里如果详细写了这一点,通常是想提醒你养成释放资源的习惯。
3. 逐个拆解核心玩法:二维数组棋盘、碰撞检测与图片分块
这七个小游戏看着五花八门,拆开看其实只有三套路数:基于二维数组棋盘的逻辑、基于坐标的碰撞检测、基于图像分块的绘制。把这三种主线吃透了,所有游戏的源码都能看懂。
3.1 俄罗斯方块与贪吃蛇:二维数组棋盘和先试探后移动的边界
俄罗斯方块和贪吃蛇的底层数据结构高度一致:一张二维数组棋盘,若干个有坐标的方块。区别只在于俄罗斯方块的方块是“下落”,贪吃蛇的蛇身是“移动”,但边界判断的思路都一样——先试探,再真正移动。
俄罗斯方块里,棋盘可以用int[20, 10]表示,20 行、10 列,值为 1 表示已被固定,0 表示空。当前正在下落的方块是一组 Point 坐标的集合,尝试移动左手时不能直接改坐标,要先算出移动后的新坐标,检查新坐标是否超出棋盘范围、是否撞到已固定的方块,全部通过才真正更新位置。
private readonly int[,] board = new int[20, 10]; // 20行10列,0表示空,1表示已固定 private Point[] currentPiece; // 当前下落方块包含的4个格子 private bool CanMove(int dx, int dy) { for (int i = 0; i < currentPiece.Length; i++) { int newX = currentPiece[i].X + dx; int newY = currentPiece[i].Y + dy; // 超出左右边界或超出底部,不允许移动 if (newX < 0 || newX >= 10 || newY >= 20) return false; // 撞到已经固定的方块,不允许移动 if (newY >= 0 && board[newY, newX] == 1) return false; } return true; }这段代码的核心思想是“试探 + 提交”。CanMove只做检查,不改动任何状态;真正的移动在调用方确认CanMove返回 true 之后才发生。这种写法的好处是避免了“先移动,再发现越界,再退回”带来的边界麻烦。我第一次带人看这段代码时说:你就把它当成提交前先做一次代码检查,检查不过就不部署。
贪吃蛇的移动方式比俄罗斯方块简单,因为它有一个经典的数据结构可以抄——LinkedList<Point>。蛇身就是一条链表,头部位置是新的,尾巴每走一步删掉一个节点,整个蛇身就自然地“滑”过去了。
LinkedList<Point> snake = new LinkedList<Point>(); // 蛇身,从头到尾排列 void MoveTo(Point newHead) { snake.AddFirst(newHead); // 新头部插入最前面 snake.RemoveLast(); // 移除尾部一格,保持蛇身长度不变 }如果吃到食物,只需要不执行RemoveLast,蛇身长度就涨了一格。这个做法比用数组逐个移动每个节点要干净得多,也完全符合“蛇跟着头走”的直觉。参数上要特别关注Point的精度:贪吃蛇棋盘如果每个格子是 20 像素,那么蛇头坐标移动的单位就应该是 20,而不是 1,否则蛇会变成像素级爬行,碰撞判定也会错位。源码注释里如果详细解释了这些坐标换算,那对初学者价值很大。
3.2 五子棋与连连看:赢棋判定、转弯限制和连通性搜索
五子棋和连连看都属于“棋盘类匹配”游戏,核心算法是判断规则。五子棋的好写,但新手经常写得很啰嗦。
判断五子棋有没有赢棋,不用每步都扫描整个棋盘,只扫描当前落子位置就够了。如果枚举 4 个方向,再从当前点分别向正反两个方向数同色棋子,加起来等于 5,就赢了。
private int CountDir(int[,] board, int r, int c, int dr, int dc, int player) { int count = 0; int nr = r + dr; int nc = c + dc; while (nr >= 0 && nr < board.GetLength(0) && nc >= 0 && nc < board.GetLength(1) && board[nr, nc] == player) { count++; nr += dr; nc += dc; } return count; } private bool CheckWin(int[,] board, int r, int c, int player) { int[,] dirs = { { 1, 0 }, { 0, 1 }, { 1, 1 }, { 1, -1 } }; // 横、竖、撇、捺 for (int i = 0; i < dirs.GetLength(0); i++) { int total = 1; total += CountDir(board, r, c, dirs[i, 0], dirs[i, 1], player); total += CountDir(board, r, c, -dirs[i, 0], -dirs[i, 1], player); if (total >= 5) return true; } return false; }这段代码里dirs数组存的是四个方向的增量:(1,0)是向下,(0,1)是向右,(1,1)是右下斜线,(1,-1)是左下斜线。每次落子后只需沿着这些方向数数,不用全盘遍历。初学者容易踩坑的是把GetLength(0)和GetLength(1)搞混,前者是第一维的长度,后者是第二维的长度。这个细节每篇文章都要重复一次,因为在数组棋盘代码里它是写错率最高的位置。
连连看的算法比五子棋复杂一点,核心是两个格子之间能否用不超过两次转弯的折线连接。它的经典解法是用广度优先搜索(BFS),在搜索过程中记录“当前方向”和“转弯次数”,转弯次数如果超过 2,就直接放弃这条路径。这段搜索代码在初学项目里相对高级,通常注释也会更密集,值得一行一行读。
Queue<(int x, int y, int dir, int turns)> queue = new Queue<(int, int, int, int)>(); // 初始方向设为 -1,表示还没有方向,转弯次数从 0 开始 queue.Enqueue((a.X, a.Y, -1, 0)); while (queue.Count > 0) { var cur = queue.Dequeue(); if (cur.x == b.X && cur.y == b.Y) return true; // 到达目标格子 for (int d = 0; d < 4; d++) { int nx = cur.x + dx[d]; int ny = cur.y + dy[d]; int newTurns = cur.turns; // 第一次启用方向时不增加转弯次数,后续方向改变才加 if (cur.dir != -1 && cur.dir != d) newTurns++; if (newTurns > 2) continue; // 超过两次转弯,剪掉 // 目标格子允许经过,或者到达终点 if (map[nx, ny] == 0 || (nx == b.X && ny == b.Y)) { queue.Enqueue((nx, ny, d, newTurns)); } } } return false;这里用到了一个元组(int x, int y, int dir, int turns)表示路径状态:当前坐标、当前前进方向、已经转弯的次数。newTurns > 2 continue是整个算法的核心,它把所有弯三次以上的路径全部剪掉,保证结果符合连连看规则。不过这个代码要求 C# 7 之后才有的元组语法,如果你看到的源码项目还在用老的 .NET Framework 版本,作者通常会把这种状态写成一个内部小类,而不是用元组。这说明了一个经验:读源码时先看项目目标框架版本,再判断代码写法,否则你会误会作者。
3.3 飞机大战与拼图游戏:矩形碰撞、子弹管理和图片分块
飞机大战是所有小游戏里最依赖碰撞检测的。飞机、子弹、敌机,本质上都是矩形,碰撞检测就是看两个矩形是否相交。
C# 的 WinForms 里已经封装好了一个矩形结构Rectangle,两个矩形是否重叠,直接调用IntersectsWith就行。
Rectangle bulletRect = new Rectangle(bullet.X, bullet.Y, 6, 12); // 子弹宽6高12 Rectangle enemyRect = new Rectangle(enemy.X, enemy.Y, 40, 30); // 敌机宽40高30 if (bulletRect.IntersectsWith(enemyRect)) { // 碰撞发生:加分、播放爆炸动画、从列表移除敌机 score += 100; enemies.Remove(enemy); bullet.IsAlive = false; }要注意Rectangle的第四、第五个参数是宽度和高度,刚入门的人写new Rectangle(x, y, 40, 30)时,很容易误写成“终点坐标”,结果碰撞区域比实际图形大一圈或小一圈。飞机大战里的“明明画面碰到了,却判定未碰撞”,八成都是宽度高度参数不对。
碰撞之后的处理也有一套常见套路:不用循环遍历删除全部对象,而是给对象打一个存活标记IsAlive = false,然后在下一次更新循环统一清理。否则一边遍历一边删除,很容易踩到“集合已修改”的报错。这是 C# 初学者在飞机大战源码里经常看到、但没注意到的一行注释。
拼图游戏的核心则是图形分块。图片切成四行四列或三行三列,每块原始位置和最终位置是同一个矩形,只是绘制到画布上的顺序被完全打乱了。
private void DrawPuzzle(Graphics g) { int rows = 4; // 切成4行 int cols = 4; // 切成4列 int pieceWidth = image.Width / cols; int pieceHeight = image.Height / rows; for (int row = 0; row < rows; row++) { for (int col = 0; col < cols; col++) { Rectangle src = new Rectangle(col * pieceWidth, row * pieceHeight, pieceWidth, pieceHeight); Rectangle dst = puzzlePieces[row, col].CurrentPosition; // 当前所在位置 g.DrawImage(image, dst, src, GraphicsUnit.Pixel); } } }src是原始图片里的一块区域,dst是拼图块当前所在的目标区域。只要不断交换dst的坐标,就变成了打乱和移动拼图。这里的重点是row * pieceHeight和col * pieceWidth绝对不能反过来,否则会出现切块位置错乱。很多拼图项目的注释会专门写上“行索引乘高度、列索引乘宽度”这句提示,就是因为它太容易写反。
4. 从能跑到能改:阅读顺序、代码骨架与难度参数调整
源码看懂和能自己改,是两回事。我见过太多初学者把别人代码跑起来之后,除了改改窗口标题,什么都不敢动。实际上,这套小游戏资源的工程思路非常统一,你只要按一套固定路线读下来,再动几个参数,项目就会“变成”自己的。
4.1 正确的阅读顺序:先跑起来,再按一条链路读注释
我推荐三步走:先运行,再找入口,最后沿着一次输入事件把代码读完。不要一上来就双击打开Form1.cs从第 1 行读到第 300 行,那样读 10 分钟脑子就糊了。
第一步是编译运行。每个游戏单独设为启动项目,玩三局,感受一下手感,顺便记住“哪些行为看起来不对劲”。第二步是找到窗体的Load事件和Timer.Tick事件,这两个地方是初始化和循环的入口。第三步是跟着一次完整的键盘输入走一遍:按下按键 → 触发KeyDown→ 调用移动方法 → 检查边界 → 更新坐标 →Invalidate()→Paint绘制。走完一遍,整个游戏就透明了。
这个小项目里注释密集的地方,正是上述链路的关键节点。注释里经常出现“这里不能直接用 == 判断”“此处是防止 Box 穿墙”“延迟 30 帧后销毁子弹”这类字段注释和行内注释。遇到这种注释,不要只看中文意思,要把它和前后 3 行代码一起读,因为作者写注释时心里想的是那个具体场景。
4.2 复用骨架:用状态机和 Update/Render 改造任何游戏
阅读多个游戏后你会发现,它们内部有一个隐藏的公共骨架:永远是一个主循环,循环里先做输入处理,再做逻辑更新,最后重绘。用一个枚举状态机把游戏流程拆开,代码会清晰得多。
enum GameState { Ready, // 初始状态,等待开始 Running, // 游戏中 Paused, // 暂停 Over // 游戏结束 } class GameEngine { public GameState State { get; private set; } public void Start() { State = GameState.Running; } public void Tick() { if (State != GameState.Running) return; // 只有 Running 状态才更新 HandleInput(); // 处理键盘按压和松开 UpdateLogic(); // 移动、碰撞、消除 Render(); // 调用 Invalidate 刷新画面 } }GameState枚举是一个大类拆解的策略:把“运行中”“暂停”“结束”三个状态用枚举区分开,比在每个方法里写布尔变量判断要可靠得多。刚开始接触这套小游戏项目时,你可能觉得一个游戏这么小,不用状态机也行;但当你同时读六个项目的源码时,能抽出一条公共骨架,理解速度会快很多。推荐用它去反向分析任意一个游戏:找找它是怎么表示暂停的,在Tick里有没有状态判断,就会看到作者用的其实是同一个套路。
4.3 参数调整:把难度和手感调成自己的
源码之所以适合学习,是因为所有参数都裸露在代码里,没有包在配置文件或复杂逻辑里。把下面这类参数改一改,游戏难度感和手感立刻会变。
private int fallInterval = 500; // 俄罗斯方块每下落一格的时间,单位毫秒 private int snakeSpeed = 150; // 贪吃蛇每移动一步的时间 private int bulletSpeed = 12; // 飞机大战子弹每帧移动的像素数 private int enemySpawnRate = 120; // 敌机生成的帧间隔 private int puzzleRows = 4; // 拼图行数 private int puzzleCols = 4; // 拼图列数这些参数放到对应的几个游戏代码里,效果如下表所示。改一个,运行,观察手感;宁可一次只改一个,也不要同时改三个,否则你无法判断手感变化来自哪个参数。
| 游戏 | 常用参数 | 改动方向 |
|---|---|---|
| 俄罗斯方块 | fallInterval | 值越小下落越快,难度越高 |
| 贪吃蛇 | snakeSpeed | 值越小蛇跑得越快 |
| 飞机大战 | bulletSpeed/enemySpawnRate | 子弹速度增加增加难度,敌机间隔越小难度越高 |
| 五子棋 | 棋盘行列数 / 赢棋连子数 | 棋盘越大思考范围越大,连子数改成 4 会更快结束 |
| 拼图游戏 | puzzleRows/puzzleCols | 行列数越多,拼图越难 |
参数的摆放位置也有讲究。编码好的注释习惯是只在每个类顶部声明一次,后面统一引用,不散落在方法里多个“魔法数字”。如果你发现某个游戏里同一个数值在多个方法里都硬编码了,那反而是一个很好的练习机会:把它抽到类顶部,改成字段,你会立刻看到三处调用被统一替代的效果。这比看十遍“字段注释”还管用。
5. 初学者高频踩坑:绘制闪烁、键盘延迟和坐标边界的排障记录
看源码是一回事,自己动手改起来踩坑是另一回事。这些坑不用每个都自己踩一遍,下面这五条都是我拆这类小游戏时见过多次的经典问题,按“现象 → 原因 → 解决”写出,方便你把问题对号入座。
5.1 Timer 间隔太短,CPU 占用飙升且手感错乱
现象:程序一运行,CPU 占用就冲到很高,窗口拖动时明显卡顿;游戏里方块下落速度看起来比设置的快得多。
原因:Timer.Interval写得太小,比如 1ms 甚至 5ms,重绘事件高频触发,导致 UI 线程被大量无意义的刷新占满。部分人在主循环里又把Thread.Sleep和Timer混用,结果 Timer 事件与控制逻辑互相叠加,游戏速度失控。
解决:System.Windows.Forms.Timer的最小有效刷新,通常建议是 15ms 以上;普通入门小游戏 40ms 手感就不错。若要追求高帧率,也要先做逻辑更新,再重绘,而不是每 5ms 无条件刷屏。判断方法是打开任务管理器看 CPU 占比,同时在窗口里看移动是否顺滑。记住,Windows 的消息循环本来就有时间分辨率限制,Timer 不是越短越快,而是越短越容易崩。
5.2 按住方向键,方块连续移动好几格
现象:明明只轻轻按了一下方向键,方块或蛇头却连续移动了两三格。
原因:没有区分“按下”和“按住”。系统在按键按住时会自动触发多次KeyDown重复消息,如果你每次KeyDown都直接执行移动,方块就会按系统重复频率连续移动。另一个常见原因是把移动逻辑放在KeyPress事件里,这个事件本身就会因长按而重复触发。
解决:在KeyDown里只记录按键状态,在KeyUp里移除状态,然后在Timer的逻辑更新里统一根据按键状态集合移动一次。
private HashSet<Keys> pressedKeys = new HashSet<Keys>(); private void Form_KeyDown(object sender, KeyEventArgs e) { pressedKeys.Add(e.KeyCode); // 只记录,不处理移动 } private void Form_KeyUp(object sender, KeyEventArgs e) { pressedKeys.Remove(e.KeyCode); // 松开后移除 } private void HandleInput() { if (pressedKeys.Contains(Keys.Left)) MovePiece(0, -1); if (pressedKeys.Contains(Keys.Right)) MovePiece(0, 1); if (pressedKeys.Contains(Keys.Down)) HardDrop(); }这样键盘的自动重复就不会影响游戏节奏,因为真正的移动频率由Timer决定,而不是由系统键盘消息决定。你还会发现,用HashSet<Keys>可以同时支持多个键,比如飞机大战里一边按方向键躲子弹,一边按空格发射子弹,互不冲突。如果你在源码里看到作者用bool[]数组或List<Keys>,最终目的也一样,只是HashSet的去重和查找效率更舒服。
5.3 方块或蛇明明还在画布里,却判定越界
现象:游戏画面里,方块显然还在窗口内部,但程序已经判定它撞到了边界,或者碰撞检测一直对不上。
原因:逻辑层用的是棋盘坐标,绘制层用的是像素坐标,两边换算差了一个偏移。最常见的是把“行索引乘单元格大小”当作坐标,却忘了加上棋盘左上角在窗口里的Margin或Padding偏移,导致逻辑边界和视觉边界差几十像素。
解决:把所有判断统一在一套坐标系里。推荐逻辑判断只用棋盘坐标,绘制时再临时换算像素坐标。也就是说,移动、边界、碰撞都用数组下标,只有在DrawImage或DrawRectangle时才做乘法和加减偏移。
// 棋盘坐标 -> 像素坐标,cellSize 是每个格子像素数,offsetX/offsetY 是画布偏移 int pixelX = col * cellSize + offsetX; int pixelY = row * cellSize + offsetY;这里的offsetX和offsetY必须要和窗体的画布摆放位置一致。初学者常在这里踩坑,因为画布控件放在窗体正中央时,边框和标题栏会让坐标起点不在(0,0)。把换算集中成两个方法,比在 20 个地方各写一遍乘除法要可靠得多。
5.4 在线程里更新界面:System.InvalidOperationException 线程间操作无效
现象:运行一段时间后,程序抛异常“线程间操作无效:从不是创建控件‘xx’的线程访问它”。
原因:有些小游戏为了做动画或音效,会开一个后台线程,在线程里直接改进度条文本或图片位置。WinForms 的控件本身是线程不安全的,不能由创建它的线程之外的其他线程直接修改。比如贪吃蛇里放背景音乐的Thread,结束音乐时顺手把label.Text给改了,这个异常就来了。
解决:使用Control.Invoke把 UI 更新丢回主线程执行;更简单统一的方案是后台线程只计算数据,数据计算完以后标记一个“需要刷新”的布尔值,由 UI 线程的Timer读取并更新界面。
private void UpdateFromBackgroundThread(string text) { if (this.InvokeRequired) { // 如果当前线程不是 UI 线程,通过 Invoke 把方法调度回 UI 线程 this.Invoke((MethodInvoker)(() => label1.Text = text)); } else { label1.Text = text; } }InvokeRequired判断当前线程是否是 UI 线程,是就直接更新,不是就交给Invoke。用了这一段,跨线程 UI 更新问题基本就能解决。但对于小游戏项目,我更建议别一上来就引入多线程:主循环用Timer,复杂计算放后台线程后用标志位等待,能避免大部分线程问题。毕竟,处理线程切换的代价比逻辑本身还高。
5.5 注释很多但字段命名混乱,读代码读串线
现象:一个类里有x、x1、xx三个变量,注释虽然每行都有,但硬是分不清哪个是输入坐标、哪个是绘制坐标。
原因:作者在写代码时,从不同玩法里拷贝过代码,字段名没有统一命名规范。这在小游戏合集里非常常见,因为俄罗斯方块和拼图游戏都用坐标,但一个用的是棋盘坐标,一个用的是图片像素坐标,字段如果都叫x,读代码的人就会串线。
解决:在命名上补齐信息量,把“类型 + 用途”写进变量名里。棋盘坐标叫gridRow/gridCol,像素坐标叫pixelX/pixelY。多含义的字段直接用字段注释说明单位,是像素还是格子,是毫秒还是帧。
这算不算是项目的缺点?我觉得是,但恰好是一个绝佳的改名练习。你可以挑一个游戏,把所有x、y变量改成gridRow、gridCol或pixelX、pixelY,改完之后再跑一遍游戏。这个过程会让你比读十遍注释记住的还多。学会阅读“没有完美注释”的项目,才是拿到任何源码包后最快提升的经验。
6. 一个好用的收尾技巧:用调试绘制模式把看不见的碰撞区域显示出来
最后分享一个我拆这类小游戏项目时用过很多次的调试方法:给游戏加一个“调试绘制开关”,把所有碰撞区域、边界、检测区域画出来。这个技巧特别适合飞机大战和拼图游戏,因为它们的碰撞框和视觉图形不重合,仅凭肉眼根本看不出判定区域在哪里。
实现方式很简单,在窗体里加一个布尔字段debugMode,绘制时将碰撞矩形用醒目颜色画出来。代码如下:
private bool debugMode = true; // 调试模式开关,正式发布时改成 false private void DrawGame(Graphics g) { // 绘制所有敌人和子弹 foreach (var enemy in enemies) { g.DrawImage(enemy.Picture, enemy.X, enemy.Y, enemy.Width, enemy.Height); if (debugMode) { using (Pen redPen = new Pen(Color.Red, 1f)) { Rectangle bounds = enemy.GetBounds(); g.DrawRectangle(redPen, bounds); // 画出碰撞区域,方便观察 } } } }debugMode为 true 时,DrawGame会额外绘制红色的碰撞矩形。这样你一眼就能看出碰撞区域比敌机图片大还是小,以及子弹会不会在视觉上还没碰到敌机时就提前判定命中。这个开关要挂在绘制链路的最后,确保它画在原本图片的上层,不会被其他画面遮挡。
我自己第一次用这个技巧时,发现了一个以前一直没想通的问题:飞机大战里子弹明明从敌机旁边擦过去,系统却判定命中。打开调试绘制后立刻看到,原来子弹的碰撞矩形宽度写成了 40,而子弹图片只有 6 像素宽,等于子弹附带了一个比自己大六倍的隐形身体。把new Rectangle(bullet.X, bullet.Y, 6, 12)修正之后,一切恢复正常。从那以后,我每次调试移动类游戏,都会强制先把这个开关打开,再一个个调碰撞参数,直到所有碰撞框和视觉画面重合为止。希望这套思路对你也有帮助,祝你把这些 C# 小游戏项目真正读进自己的代码库。
本文还有配套的精品资源,点击获取