简介:在桌面应用开发中,游戏循环与资源管理是决定流畅度的关键。WinForms通过Timer驱动逻辑帧,将移动、碰撞检测与绘制分离,实现稳定的实时交互。而高频创建对象的场景,如飞机大战的子弹,常借助对象池避免频繁内存分配,降低GC压力。理解这些基础机制后,再结合List 管理贪吃蛇身体、方向数组判断五子棋胜利、逆序对校验拼图可解性,就能系统掌握C#小游戏开发的核心模型。本文以七个经典WinForms小游戏为例,从游戏循环、碰撞检测到对象池重构,逐步拆解每个项目的共性骨架与实现细节,帮助初学者在动手实践中建立清晰的工程边界感。
1. C#七个小游戏项目摆在面前:先搞清楚它到底能教会你什么
如果你刚把C#语法翻过一遍,下一步最容易卡住的地方不是类和委托,而是“到底该写点什么”。这套七个小游戏项目把飞机大战、俄罗斯方块、贪吃蛇、拼图游戏、连连看、五子棋六个经典玩法放进同一个解决方案,再加一个总控启动窗口,凑成七个可学的小项目。它们全部基于WinForms桌面窗体,你可以用最简单的界面逻辑把游戏跑起来,每改一个规则都能立刻看到结果。注释详细在这里不是摆设,它帮你省去“这个变量干嘛用的”那种从头猜的成本,真正该做的还是动手改规则:让贪吃蛇穿墙、让飞机大战的子弹变快、让拼图洗牌不再无解。能做到这一步,C#才算真正入了门。
2. 动手之前,先把七个项目的共性骨架拉出来
2.1 一个解决方案里同时放下六个游戏和启动主窗口
七个小游戏如果全塞在一个Form里,前期写起来很随意,等代码涨到两三千行,变量名和事件函数就开始互相打架。我一般建议第一次接触这套项目时,把它当成多项目解决方案来组织:六个游戏项目分别叫AircraftWar、TetrisGame、SnakeGame、PuzzleGame、LinkLinkGame、GobangGame,另加一个Launcher启动器项目。启动器作为主窗口,负责显示按钮列表并打开各个游戏窗体。项目引用方向永远是启动器指向游戏项目,不许反着引用。这样你单独学俄罗斯方块时,只需要进入TetrisGame项目,命名空间、画布控件和规则代码全部隔离,不像在单窗体里翻找时那样容易误改其他游戏的逻辑。
// 启动器主窗口里的按钮事件:打开贪吃蛇窗体 private void btnOpenSnake_Click(object sender, EventArgs e) { // using保证窗体关闭后释放非托管资源 using (SnakeGame.SnakeForm form = new SnakeGame.SnakeForm()) { // ShowDialog是模态打开:贪吃蛇不关,启动器主界面不可点 form.ShowDialog(); } }逻辑说明:这个函数的目的是“新建游戏窗体并显示”。用using包住窗体实例,是为了在关闭后尽早释放窗体句柄和GDI资源,这是WinForms里比较稳的写法。调用ShowDialog而不是Show,是因为初学者最常犯的错是连点按钮弹出多个游戏窗口,模态窗体在关闭前不允许主界面重复触发,能在游戏没有复杂生命周期管理能力时少兜一层bug。
参数说明:using语句的作用域就是整个游戏窗口的存活周期,Close之后Dispose会自动补上。如果以后想改成非模态多个窗口并列运行,把ShowDialog换成Show也可以,但要自己负责在窗体关闭时释放实例,否则句柄会越积越多。依赖方向则决定了三个项目是否能并行编译,其实这是很值得在初学阶段就建立的项目边界感。
2.2 选对游戏循环驱动方式:Timer、线程与帧率
很多初学者把“游戏循环”理解为while(true)死循环,这个想法在控制台里没问题,但在WinForms里一旦放到UI线程执行,窗口会直接卡死,连关闭按钮都点不动。传统小游戏项目里,正确的打开方式是System.Windows.Forms.Timer:它每隔Interval毫秒触发一次Tick事件,我们在Tick里完成“移动对象、碰撞检测、刷新画布”这一整套逻辑。飞机大战这类节奏快的游戏,Interval可以给到30ms;贪吃蛇、俄罗斯方块这类需要思考节奏的,建议从80-120ms开始,跑顺了再往下调。需要留意的是,Timer只是定时器而非硬实时调度器,它在主线程空闲时触发,CPU一旦被其他任务占满,Tick都会延后,画面就会一顿一顿,这也是很多人在低配机器上觉得游戏像幻灯片的原因。
private System.Windows.Forms.Timer gameTimer; private void GameForm_Load(object sender, EventArgs e) { gameTimer = new System.Windows.Forms.Timer(); gameTimer.Interval = 100; // 100ms走一格,约每秒10帧逻辑 gameTimer.Tick += GameTimer_Tick; gameTimer.Start(); } private void GameTimer_Tick(object sender, EventArgs e) { UpdateGameObjects(); // 逻辑帧:处理输入、移动对象 CheckCollisions(); // 碰撞检测,失败就停Timer gameCanvas.Invalidate();// 让画布重新绘制,真正上屏 }逻辑说明:三次调用顺序不能乱。先更新状态,再检测碰撞,最后才让画布重绘。如果把Invalidate放到前面,画布会先用上一帧的状态绘制,画面和逻辑错了一帧,肉眼看到的就是“子弹已经撞到敌机,敌机却还在画面上”。碰撞失败时要在CheckCollisions里调用gameTimer.Stop,否则Tick还会继续跑,界面会显示假死。
参数说明:Interval是最核心调参点,数值越小动作越跟手,CPU占用也越高。对七个小游戏这个量级,30ms是偏抖的极限值,100ms更平衡;如果发现飞机大战子弹密集时画面频繁掉帧,优先把子弹数量压下去,而不是把Interval调到10ms——那只会加重UI线程在更短周期内处理更多工作的压力,会变得更糟。
如果往后游戏逻辑重到一次Tick要跑几十毫秒,再考虑把逻辑搬进后台线程,用BackgroundWorker或独立线程跑逻辑,UI线程只负责绘制。但请先确认自己已经理解控件跨线程更新这个坑,否则会遇到更费解的白屏和回调异常。这两种驱动方式各有利弊,小项目在没到性能瓶颈前不要主动引入线程。
2.3 中文注释在“学习版”里该怎么写
我见过不少“注释详细”的项目,其实是把每一行都写一句“这是整数”的流水账。真正的学习注释只解决三个问题:这个变量是干什么的;这段循环在干什么;这个分支为什么这么写。拿到这套项目的你,也应该按这三个标准去检验代码质量,而不是站在“注释越多越好”的立场上自欺。
// 蛇在当前方向向量下的下一个头部位置。 // dx=1表示向右移动,dy=1表示向下移动,同一轴方向上两个值只会有一个非0。 Point nextHead = new Point(snakeBody[0].X + dx, snakeBody[0].Y + dy); // 先判断新头部是否越界,再判断是否撞到身体。 // 顺序不能反过来:如果先Insert,再判断Contains,头部会把自己也算进去。 if (nextHead.X < 0 || nextHead.X >= gridCols || nextHead.Y < 0 || nextHead.Y >= gridRows || snakeBody.Contains(nextHead)) { StopGame(); // 游戏失败:停止计时器并给出提示 return; }逻辑说明:这段注释里最有价值的部分不是“新头部位置”,而是“为什么先判断越界再判断撞自己”的顺序问题。如果先Insert再Contains,蛇头会默认出现在列表里,无论什么情况都判定为撞到自己,游戏永远一开始就结束。命名习惯上,gameCanvas这样的控件名直接说明了用途,gridCols比col2这种神秘缩写好用得多。学习过程中,看到注释后应该顺手读一遍实现,不要跳过。
参数说明:gridCols和gridRows是画布按网格拆分的列数和行数。网格尺寸变化会影响游戏难度:网格越大,蛇越难吃到食物;网格越小,蛇越容易碰到边界。做版本迭代时,把这两个值抽成常量,比在代码里到处写魔法数字好维护得多。这也是我维护这套项目时给自己定的注释纪律:注释里永远不写“显然”“默认”这类词,而是写清楚变量含义和为什么。
3. 拆开贪吃蛇和飞机大战:先跑通一整套能改的代码
3.1 用List 而不是二维数组来管理蛇身体
贪吃蛇的身体天然是一个动态变化的有序集合:头进尾出。用二维bool数组标记地图当然可以,但要移动整条蛇就得双层for循环遍历地图,想知道身体在哪个格子还得额外维护一份列表。在WinForms小游戏里,最常见的做法是直接使用List ,把蛇头放在下标0,越往后离尾部越近。增删只涉及两个操作:在0号位插入新头部,然后判断是否删掉尾部;吃食物时保留尾部,长度自然增加。这个模型对应的正是C#里List的两个高频方法:Insert和RemoveAt。
// 蛇身列表,下标0固定为头部位置 private List<Point> snakeBody = new List<Point>(); // 初始化一条长度为3的蛇,向右移动 private void ResetSnake() { snakeBody.Clear(); snakeBody.Add(new Point(5, 3)); // 头 snakeBody.Add(new Point(4, 3)); snakeBody.Add(new Point(3, 3)); // 尾 }逻辑说明:List 的好处在于“添加头和移除尾”的操作天然贴合蛇的移动模型。移动时先计算新起点,头部Insert(0)新坐标,再用RemoveAt(body.Count-1)删掉旧尾巴。吃到食物时不执行RemoveAt即可。判断撞到自己时,用List自带的Contains方法判断新头部坐标是否已在列表中,一行搞定,不用手动遍历地图。
参数说明:初始蛇长、初始位置、方向都要和地图边界对得上。上面初始位置在(5,3)向右移动,X坐标会逐渐增大,等X撞到gridCols边界时就是墙。初始长度设太短,长度1,游戏一开局就瞬间结束;设太长,初学阶段容易一开局就绕不动。3到5是常见的起步值,地图尺寸变化时记得同步调整。
3.2 先碰撞后移动,还是先移动后碰撞:贪吃蛇的死活其实在这里
我改这类项目时见过最多的问题,不是方向反了,就是蛇头穿墙闪一帧。根本原因是把“移动”和“碰撞检测”当成了一步,或者反过来在按键事件里直接改蛇的位置。正确做法是把Tick事件拆成三个段落:先按保留方向算出新头部位置,再对新头部做边界和自身碰撞检测,最后才把新头部真正Insert进列表。按键事件只准改dx和dy方向向量,不做移动。常见方向反了的原因也在这里——如果在KeyDown里写的是“蛇沿反方向移动”,玩家按左键时蛇其实先往右走了一帧才响应,就产生了“方向反了”的感官错位。
// 键盘事件只更新方向向量,不移动蛇 private void SnakeForm_KeyDown(object sender, KeyEventArgs e) { switch (e.KeyCode) { case Keys.Left: if (dx != 1) { dx = -1; dy = 0; } // 禁止直接掉头,否则会撞到自己 break; case Keys.Right: if (dx != -1) { dx = 1; dy = 0; } break; case Keys.Up: if (dy != 1) { dx = 0; dy = -1; } break; case Keys.Down: if (dy != -1) { dx = 0; dy = 1; } break; } }逻辑说明:每个case里先判断当前方向再设置新方向。如果当前蛇头正向右(dx=1),按下左键,if条件会阻止这次方向更新,防止蛇180度掉头直接咬到自己。这个限制不是可选项,是贪吃蛇玩法的基本矛盾,略掉之后你会发现蛇经常毫无预兆地死亡,而且很难从画面里看出来到底哪一步错了。
参数说明:方向向量的取值是{-1,0,1}三个值,dx、dy只在各自维度上有一个非0。如果两个同时非0,蛇就会斜着走,在网格坐标系下越飘越偏。每次修改后都应保证只有一个轴在运动。初学阶段可以把dx、dy打到输出窗口里调试,能直观看到按键事件的响应顺序是否正确。
3.3 飞机大战子弹对象池:为什么不能每次都new一颗子弹
飞机大战里的子弹是典型的“高频创建、短生命周期”对象。每按一次空格new一个Bullet,清除时再交给GC回收,小游戏几百发子弹可能看不出区别,但连续打几分钟后,垃圾回收会偶发地卡一下UI线程。七个小游戏项目里真正值得学的并不是GDI绘制,而是用对象池把子弹的生命周期管理起来。这个经验以后写上位机通信里的报文缓存、写游戏里的可复用实体时都能直接迁移,不是只在这一个项目里生效的奇技淫巧。
private List<Bullet> bulletPool = new List<Bullet>(); private Bullet AcquireBullet(Point position) { // 先找池子里已经“死亡”的子弹复用 foreach (Bullet b in bulletPool) { if (!b.Alive) { b.Reset(position); // 重置坐标、速度,重新标记为存活 return b; } } // 找不到再新建,并放回池中 Bullet newBullet = new Bullet(position); bulletPool.Add(newBullet); return newBullet; }逻辑说明:对象池的核心理念是“复用而不是重建”。每次射击时从列表中找Alive为false的子弹,调Reset把坐标和速度重置为初始状态;只有所有子弹都在飞时才新建。每一帧还要顺便做一轮状态清理,把飞出边界或击中目标后的子弹Alive置为false,但不要立刻从List删除——删除动作只会发生在新子弹需要复用空间的时候。
参数说明:对象池不需要精确限制数量,它的意义在于最坏情况下同时有多少子弹在场,池子容量就会增长到那个数,之后不再增长。如果发现子弹太多导致性能下降,不要限制池容量,要限制射击频率,比如给射击动作加一个冷却判定。这个思路同样适用于拼图游戏里的碎片对象和连连看里的数字块,它们都是“批量、短生命周期、可复用”的典型。
3.4 拼图游戏洗牌:加入逆序对校验,别让玩家永远拼不出来
拼图游戏在界面上的难点是拖动碎片,真正的难点是开局洗牌。如果用Random简单把碎片顺序打乱,玩家经常会在最后遇到“所有位置都对,只有最后两格互换”的死局。这类问题的数学本质是逆序对奇偶性。以常见的4x4拼图为例,把碎片展平成数组,空白记作0,统计逆序对数量,一个可解的4x4拼图必须满足:逆序数为偶数且空白格位于从底部数起的偶数行,或者逆序数为奇数且空白行是奇数行。洗牌后如果发现不可解,最廉价的做法是交换两个相邻的非空白碎片,重新计算逆序数。
// 判断4x4拼图是否可解 // tiles是展平的碎片数组,0代表空白格 private bool IsSolvable4x4(int[] tiles, int blankRowFromBottom) { int inv = CountInversions(tiles); return (inv % 2 == 0) == (blankRowFromBottom % 2 == 1); } private int CountInversions(int[] tiles) { int count = 0; for (int i = 0; i < tiles.Length; i++) { if (tiles[i] == 0) continue; // 空白格不参与统计 for (int j = i + 1; j < tiles.Length; j++) { if (tiles[j] != 0 && tiles[i] > tiles[j]) count++; // 位置在前但数字更大,构成逆序对 } } return count; }逻辑说明:逆序对的定义是“前面的数字比后面的大”。CountInversions用两层循环逐个统计,因为碎片长度只有15或16,O(n²)性能完全够用。IsSolvable4x4把逆序数奇偶和空白行奇偶叠加,得到最终可解性。3x3、5x5这类奇数宽度的拼图会把条件反过来,具体规则可以根据格子数推导,但初学阶段知道“洗牌后必须过一遍可解性校验”就已经足够避免最折磨人的死局。
参数说明:blankRowFromBottom的计算方式是“地图高度减空白格实际y坐标”,而不是从顶部数。很多初学者会在这里算反,导致校验结果刚好相反。实现时建议先打印空白行的两个结果做交叉验证。另外一个实用技巧是初始洗牌永远从“已完成状态”出发,连续随机做几百次合法交换,这样生成的盘面天然可解,不必再事后修正,体验上会更柔和。
4. 俄罗斯方块、连连看、五子棋:把规则翻译成状态判断
4.1 俄罗斯方块:用一组矩阵描述七种形状与旋转
俄罗斯方块的七个形状,本质是7个小矩阵。矩阵中值为1表示有方块,0表示空。把形状存成int[,]后,旋转可以用“转置+水平反转”实现,顺时针旋转90度一次搞定。有些版本为了简化会给每种形状单独备4个旋转状态,但那样数据量变大,而且把逻辑藏在了数据表里,对初学者理解旋转过程反而没帮助。更好的教学姿势是用一个RotateMatrix函数动态旋转,再套一层越界检测:如果旋转后被障碍物或边界挡住,就回退到原形状,这是最容易理解的旋转判定方式。
// 七种基础形状,O型是2x2,其余用3x3或4x4表示 private static int[][,] SHAPES = new int[][,] { new int[,] { {1,1,1,1} }, // I new int[,] { {1,1}, {1,1} }, // O new int[,] { {0,1,0}, {1,1,1} }, // T new int[,] { {1,1,0}, {0,1,1} }, // S new int[,] { {0,1,1}, {1,1,0} }, // Z new int[,] { {1,0,0}, {1,1,1} }, // J new int[,] { {0,0,1}, {1,1,1} } // L }; // 顺时针旋转矩阵 private int[,] RotateMatrix(int[,] shape) { int rows = shape.GetLength(0); int cols = shape.GetLength(1); int[,] rotated = new int[cols, rows]; for (int i = 0; i < rows; i++) for (int j = 0; j < cols; j++) rotated[j, rows - 1 - i] = shape[i, j]; return rotated; }逻辑说明:旋转本质是坐标变换,原有坐标(i,j)映射到新矩阵(j,rows-1-i)。为什么要用二维矩阵而不是一个“形状枚举+旋转标志”?因为矩阵表示的形状天然可用于碰撞检测:检查掉落方格时可以直接遍历矩阵中的1,然后与目标位置的方格逐点比较。O型旋转无效果,但其他形状宽高不同,旋转后要重新判断边界,所以RotateMatrix不能和边界检测耦合在一起写。
参数说明:GetLength(0)是行数,GetLength(1)是列数,不能对调。旋转后行数和列数交换,所以new int[,]要分配为[cols, rows]。边界检测时不要用固定地图宽度硬编码,要动态结合当前形状矩阵的尺寸,否则I型横着能放下的位置,竖着时反而会被判定穿墙。这套旋转函数以后也可以泛型化,对不同尺寸的矩阵统一复用。
4.2 连连看:最多拐两次弯的路径搜索
连连看的寻路是一个固定步数内的搜索问题,但对初学者来说直接上BFS整张地图,其实比规则本身更难懂。惯用做法是把问题降维成三种情况:直线直达、只拐一次弯、最多拐两次弯。每次消除前先检查两个格子的图案类别,再用直线连通的函数做基础判断。这个拆解方式很契合“把大问题切成小问题”的思路,也是做成新手项目的原因之一。
private bool CanConnect(Point a, Point b) { // 消除条件:不是同一格,且图案相同 if (a == b) return false; if (board[a.Y, a.X] != board[b.Y, b.X]) return false; // 0折连接:同在一条直线且路径无阻挡 if (IsLineClear(a, b)) return true; // 1折连接:拐点取(a.X, b.Y)或(b.X, a.Y) Point c1 = new Point(a.X, b.Y); Point c2 = new Point(b.X, a.Y); if (IsLineClear(a, c1) && IsLineClear(c1, b)) return true; if (IsLineClear(a, c2) && IsLineClear(c2, b)) return true; // 2折连接:横向或纵向先让一格,再套1折逻辑 if (TryTwoCorner(a, b)) return true; return false; }逻辑说明:IsLineClear的内部实现就是沿着直线逐个判断board数字是否为空,遇到非空返回false。0、1、2折三种情况由判断顺序决定,也是玩家视角里的“连通”规则。这里的board建议按[y,x]下标存取,保证它和二维坐标数组的行列方向一致。消除一对后,要先把map对应位置置0,再触发重绘;不要先重绘再清除数据,否则一帧内可能又看到原来的图案闪烁。
参数说明:拐点坐标必须是整数且在地图边界内。TryTwoCorner的实现是枚举所有可能的横向/纵向通道,每个通道再判断两条直线是否都连通。循环上限是地图的长边,不必担心无界循环。如果地图尺寸是8x10,两点连线最大长度不会超过长边10。这种搜索对初学者足够,后续扩展是换成真正的BFS,但先能把三折逻辑跑通已经很能说明问题。
4.3 五子棋:方向数组与五连判断
五子棋比前面的游戏多一个对手问题,但核心逻辑依然可以很简洁。黑白双方落子后都要做胜负检测,因此判胜函数要能对任意颜色生效。实现上有一种非常标准的写法:方向数组。四个方向(水平、垂直、主对角线、副对角线)都用两个整型增量表示,判断时从落子点往正方向数同色棋子,再往反方向数一遍,加起来大于等于5就是胜利。
private bool CheckWin(int[,] board, int x, int y) { // 四个方向:水平、垂直、主对角线、副对角线 int[] dirX = { 1, 0, 1, 1 }; int[] dirY = { 0, 1, 1, -1 }; int color = board[y, x]; if (color == 0) return false; for (int i = 0; i < 4; i++) { int count = 1; // 正方向连续计数 count += CountLine(board, x, y, dirX[i], dirY[i], color); // 反方向连续计数 count += CountLine(board, x, y, -dirX[i], -dirY[i], color); if (count >= 5) return true; } return false; } private int CountLine(int[,] board, int x, int y, int dx, int dy, int color) { int count = 0; int nx = x + dx, ny = y + dy; // 边界内且同色才继续数 while (nx >= 0 && nx < board.GetLength(1) && ny >= 0 && ny < board.GetLength(0) && board[ny, nx] == color) { count++; nx += dx; ny += dy; } return count; }逻辑说明:CheckWin把“四个方向”翻译成方向数组后,计数和统计逻辑全部复用。方向数组这种写法以后在很多网格游戏里都能用到,从扫雷的地雷展开到迷宫寻路都适用。注意传入的x,y必须是最新落子点,而不是遍历全部棋盘,否则每次落子都会有额外计算开销,而且误报可能性变高。
参数说明:board[y,x]的行列边界要用GetLength(0)和GetLength(1)动态取。棋盘固定在15x15时,5连是最小胜利条件。如果改成六子棋,只需要把5改成6。想做简单电脑对手,可以扫描每个空点,对黑白双方各计算一个“连续子数+开放端数”的威胁值,取最大值落子,能做出一点阻挡对方的感觉。学有余力再做真正的博弈搜索也来得及。
5. C#小游戏开发避坑:初学者最容易踩的五个坑
下面五条是我把这些项目做了三轮之后攒下的血泪经验。每一条都按“现象、原因、解决”的顺序写,排查的时候可以当清单用。
5.1 控件一多,界面就卡成PPT
现象:玩飞机大战时,画布上的Label、TextBox、PictureBox一多,移动鼠标都感觉画面不跟手,点击响应明显变慢。 原因:WinForms默认每个控件都有独立的窗口句柄,控件数量太多时,UI线程要花大量时间在句柄的消息循环上,绘制也变成逐控件刷新,整体性能就崩了。 解决:先把游戏画面收敛到一个Panel或PictureBox上,用GDI+在这个画布上统一绘制,而不是每个单位都放一个控件。再给画布设置DoubleBuffered=true,绘图过程先在内存缓冲里完成,一次性上屏。绝大多数小游戏在实行“单画布+双缓冲”之后,帧率问题都会消失。
5.2 Timer回调里改控件报“跨线程”错
现象:用System.Threading.Timer或后台线程更新进度条、Label文本,运行时会抛InvalidOperationException,提示“线程间操作无效:从不是创建控件的线程访问它”。 原因:WinForms要求所有操作控件UI属性的代码都在创建该控件的UI线程上执行。后台线程直接改Text、Visible会触发跨线程检查,教程里没提这个坑的话会让人一头雾水。 解决:使用System.Windows.Forms.Timer,它会自动在UI线程触发Tick;如果确实需要后台线程,就在回调里先判断InvokeRequired,为true时用Invoke把更新动作扔回UI线程。能用前者就优先前者,跨线程是这个项目里最容易变成黑匣子的地方。
5.3 贪吃蛇撞墙后游戏假死,什么按键都没反应
现象:蛇头撞墙后,窗口没有崩溃,但游戏画面停住,键盘没反应,连菜单也点不动。 原因:Timer的Tick里在Game Over分支用while(true)空转等待重新开始,导致UI消息泵被阻塞,按键自然进不了事件队列。 解决:不要在判负分支里写死循环。正确流程是timer.Stop(),再把蛇复位,或者显示一行文字提示玩家按空格重开。如果使用“重开倒计时”,用另一个Timer或直接刷新文字都可以。实在想阻塞式等待,也要用Application.DoEvents在循环里吐出消息,但那属于后患很多的写法,不推荐。
5.4 连连看怎么点都连不上线,路径明明是空的
现象:视觉上两个相同图案之间没有任何障碍,但点击后连接判断总失败。 原因:最容易出错的点是把Point传进去,但board的坐标行列反了。另一个常见原因是图案消除后,List里对应的对象被移除,但map数组没同步置0,残留数据仍在参与寻路判断。 解决:统一所有数据用[y,x]下标读board,绘制时再从board坐标换算成屏幕像素坐标。消除后先写map[y,x] = 0,再清理List。做二维小游戏时在项目开头就定好“数据层坐标和绘制层坐标分开”的规则,能省掉一大半定位成本。
5.5 拼图最后两格永远换不回来
现象:再怎么拖,拼图都只剩右下角两个碎片相反,就是无解。 原因:直接Random.Shuffle没有做可解性校验,生成了奇数逆序的初始盘面,这种盘面在数学上永不可解。 解决:洗牌后套用可解性判断,参考3.4的逆序对计算,如果结果不可解就交换相邻两个非空白碎片,重新计数,直到可解。真正的洗牌代码要写在一个循环里,最多循环几次也能收敛,因为每次交换都会改变逆序奇偶性。也可以直接用“从可解的完成状态回退N步交换”的办法,保证每步都是合法操作。
这些坑在我实际操作里几乎每个项目都会踩一次,大部分不在教学笔记里写的逻辑层,而在绘图坐标、列表更新时机和Timer线程这几处细枝末节。跑一轮以后你会发现,这类小游戏的重心和面试问C#时的重心高度重合:不是语法,而是资源管理、状态同步和坐标换算。
6. 六个游戏跑通之后:按自己的节奏做参数与性能验证
六个游戏都有各自可调参的量,跑通后最好做一轮“参数扫描”,而不是停在“能玩”就结束。按我当时遍历的做法,给每个游戏留下一张参数对照表,先记录“默认值跑起来什么手感”,再把某个参数改大或改小,确认差别在哪。这张表不需要多华丽,但对理解Timer和数据结构有直接的长期印象。
| 游戏 | 关键参数 | 默认起步值 | 改小/改大的效果 |
|---|---|---|---|
| 贪吃蛇 | Timer.Interval | 100ms | 改小加速,改大放缓;低于50ms后键盘事件吞键明显 |
| 飞机大战 | 射击冷却 | 300ms | 冷却越小弹幕越密,需要配套对象池,否则卡顿 |
| 俄罗斯方块 | 下落间隔 | 500ms | 改小直接提升难度,随分数动态下调更好 |
| 拼图 | 碎片边长 | 80px | 改小碎片更精细,但拖动命中难度上升 |
| 连连看 | 地图行列 | 8x10 | 行列越多,路径搜索次数越多,要同步调整寻路上限 |
| 五子棋 | 棋盘尺寸 | 15x15 | 改19x19后五连判断仍能复用,AI评估变慢 |
然后是验证步骤。先开启双缓冲,再压测:连续快速按空格发射几百发子弹,用Stopwatch统计每次Tick的执行耗时,超过3ms就查一下是不是每次都new Bullet。
// 开启双缓冲:在窗体构造函数里加到这一步 gameCanvas.GetType().GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) .SetValue(gameCanvas, true, null);逻辑说明:WinForms里PictureBox的DoubleBuffered属性默认是protected,用反射强行打开,副作用很小,适合学习阶段验证。真正生产级做法是自定义一个继承Control的画布类,在内部设置DoubleBuffered=true。这个改动对飞机大战和拼图的拖动体验影响最明显,能直接感受到重绘抖动变小。
参数说明:Stopwatch的ElapsedMilliseconds只能测到毫秒级,不用过度追求小数位,能判断“一次Tick有没有超过3-5ms”就够了。如果发现耗时随游戏时长明显增长,优先检查List里的死亡对象是否一直在积攒。这正好把前面的资源管理点串起来复习了一遍。
这套项目做完一轮后,我就顺势进入很扎实的重构阶段。因为被“控件多了就卡”这类坑折磨过,我会把“单画布绘图、列表做逻辑、坐标双开”当成本能习惯,再来写更复杂的应用就心里有底。做小游戏项目真正的价值是建立标准和边界感:哪里该用控件,哪里该用矩阵,哪里值得提前做对象池,哪里最好不要碰UI线程。越到后面越能理解,七个小游戏本身是给C#入门准备的脚手架,真正长成什么样,取决于你怎么追加自己的玩法和系统。希望这套项目的思路能成为你同样的起点。
本文还有配套的精品资源,点击获取