ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C#入门必读:六个小游戏源码逐行拆解,从游戏循环到碰撞判定

2026/9/14 4:00:36 拓冰建站 浏览量
C#入门必读:六个小游戏源码逐行拆解,从游戏循环到碰撞判定 简介一套面向C#初学者的七合一游戏开发实战资源包含飞机大战、俄罗斯方块、贪吃蛇、拼图游戏、连连看、五子棋等经典小游戏完整项目所有代码均配有详细注释覆盖窗体设计、游戏循环、碰撞检测、坐标变换、事件监听等核心知识点适合在校学生或自学编程者通过学习项目源码快速上手C#桌面应用开发。压缩包共1286个文件其中含92个.cs源文件、388个xml配置文件、146个dll运行库以及png图片、wav与mp3音频、resx资源文件等素材整体约74.9MB目录结构清晰便于逐项阅读和调试运行。目前已有1280人学习下载。借助这些贴近趣味场景的完整项目读者不仅能掌握从窗体搭建到逻辑实现的全流程还可理解游戏设计中常用的数据结构与算法思想为独立开发小型应用打下扎实基础。1. 七个小游戏项目是 C# 入门最值得逐行读的源码样板标题写着七个工程核心玩法是六个飞机大战、俄罗斯方块、贪吃蛇、拼图、连连看、五子棋外加一个把它们串起来的菜单窗体。这套组合反复出现在 C# 入门教程里不是画面好看而是每个玩法恰好压住一个知识点飞机大战练游戏循环与键盘输入贪吃蛇练列表结构的高频增删俄罗斯方块练二维数组连连看练路径连通五子棋练边界判定。刚学完类与对象的人缺的不是语法而是把语法组合起来的能力。顺着注释详尽的源码逐行读再动手改参数比刷练习题更能进入 C# 面向对象的思考方式。这篇就按从易到难的顺序把六个玩法的骨架、参数和坑拆开讲。2. 飞机大战C# 游戏循环、Timer 刷新与键盘输入的骨架2.1 游戏循环是「输入、更新、绘制」三段式不是事件驱动控制台程序天然是顺序执行的做完一件事就结束游戏要一直运行就必须自己造一个循环。WinForms 工程里这个循环藏在 Timer 控件里Tick 事件每 50 到 100 毫秒触发一次控制台项目则直接写 while (true)。不管哪种形式循环体里永远只有三件事读输入、更新状态、重绘画面。理解这一点飞机大战的代码就不再是散落的 Console.WriteLine而是一台每帧运转一次的状态机。新手最常见的错误是把 ReadKey、Sleep 和绘图写在同一段逻辑里结果按键要等绘制完才响应画面还一闪一闪。正确顺序是先处理输入再按固定间隔刷新刷新时才清屏和重绘。注释详尽的源码通常会在循环顶部标注「输入」「更新」「绘制」三个区段读的时候先找这三个注释整个文件的结构就出来了。2.2 最小可跑的控制台飞机大战移动、射击、刷新三件事下面这段是控制台版飞机大战的最小骨架只保留核心循环去掉计分和敌机逻辑。static void Main() { Console.CursorVisible false; int planeX 20, planeY 15; int bulletY -1; // -1 表示子弹当前不存在 int cols 40, rows 20; DateTime lastTick DateTime.Now; while (true) { // 输入段不阻塞循环地读按键 if (Console.KeyAvailable) { ConsoleKey key Console.ReadKey(true).Key; if (key ConsoleKey.LeftArrow planeX 0) planeX--; if (key ConsoleKey.RightArrow planeX cols - 1) planeX; if (key ConsoleKey.Spacebar bulletY -1) bulletY planeY - 1; // 在飞机头顶生成一发子弹 } // 更新段固定间隔推进子弹 if ((DateTime.Now - lastTick).TotalMilliseconds 100) { lastTick DateTime.Now; if (bulletY 0) bulletY--; // 绘制段先清屏再按坐标画 Console.Clear(); Console.SetCursorPosition(planeX, planeY); Console.Write(^); if (bulletY 0) { Console.SetCursorPosition(planeX, bulletY); Console.Write(|); } } } }逻辑说明KeyAvailable 配合 ReadKey(true) 是控制台非阻塞读键的标准写法没有按键时直接跳过输入段循环不会停住。子弹用单个变量 bulletY 管理-1 表示当前没有子弹它把「子弹是否存在」和「子弹高度」两个信息合并成一个状态值这是入门项目里很常见的设计。刷新间隔用 DateTime 差值控制避免 Sleep 阻塞循环导致按键丢失。参数说明刷新间隔从 100ms 改小到 30ms飞机移动会变快但 Console.Clear 的闪烁会更明显cols、rows 决定边界判定按键分支里用的是「先判断再移动」而不是移动后再把坐标拉回来这样就不会出现飞机瞬间闪出边界又弹回的现象。想实现按住方向键连续移动需要额外维护一个 bool 数组记录每个键的按下状态因为 KeyAvailable 只能告诉你「有按键排队」不能告诉你「某个键现在还按着」。提示Console.Clear 每帧调用一次SetCursorPosition 的目标坐标如果超出窗口缓冲区大小会直接抛异常控制台游戏的地图行列参数必须和窗口尺寸同步调整。2.3 三个必调的体验参数帧率、边界和子弹数量参数位置调整效果注意点刷新间隔 100ms更新段判断条件越小画面越流畅控制台低于 50ms 闪烁明显要考虑双缓冲或换 WinForms地图宽高 40×20左右边界判断决定活动范围必须小于窗口缓冲区否则 SetCursorPosition 抛异常bulletY 单发状态射击与弹道逻辑控制射击节奏改成 List 可做多发子弹但要同步做越界清理把这三个参数各改一遍飞机大战这个项目就算读通了。源码里如果子弹是一组对象而不是一个变量说明已经进入类与对象层面的设计子弹类负责坐标和移动玩家类负责输入所有碰撞检测集中在更新段统一做这也是注释较长的版本会花最多篇幅解释的部分。3. 贪吃蛇与俄罗斯方块C# 网格数据结构与碰撞判定的写法3.1 先选数据模型再写逻辑List 和 int[,] 怎么分贪吃蛇和俄罗斯方块看起来都是网格游戏但数据结构的选择正好相反。贪吃蛇的身体是一条会移动的链每次移动只在头部增加一格、尾部减少一格用 List 存坐标序列最贴合这个行为Insert(0, ...) 和 RemoveAt(Count - 1) 都是尾部操作Cost 很低。反过来如果用 int[,] 存蛇身每次移动都要把整个数组扫一遍重建代码绕且容易漏边界。俄罗斯方块正好反过来场景是一个固定的 10×20 网格需要频繁检查某一行是否填满、把整行删除并把上面所有行下移这些操作都要求按行列随机访问int[,] 是最自然的选择。当前正在下落的方块用一个小一点的 int[,] 保存形状旋转就是对这个矩阵做坐标变换。玩法推荐结构为什么不推荐贪吃蛇身体List头插尾删天然模拟移动int[,] 每次全量扫描贪吃蛇碰撞图bool[,]可选快速判断某格是否被占和身体列表重复维护容易不一致俄罗斯方块场景int[,] 10×20消行需要整行访问和搬移List 中间删除效率低、代码丑当前方块形状int[,] 4×4旋转等于矩阵运算用字节编码形状难读难改这段对照是阅读源码前必须先有的判断因为很多初学者把两套玩法都写成 List 或都写成二维数组代码能跑但稍微加一个功能就开始出错。3.2 贪吃蛇移动先算新头再决定删不删尾巴static void MoveSnake(ListPoint snake, Point dir, Point food, int rows, int cols) { Point newHead new Point(snake[0].X dir.X, snake[0].Y dir.Y); // 墙碰撞出界直接结束 if (newHead.X 0 || newHead.X cols || newHead.Y 0 || newHead.Y rows) { GameOver(撞墙); return; } // 自碰撞新头坐标已经在蛇身列表里 if (snake.Contains(newHead)) { GameOver(咬到自己); return; } snake.Insert(0, newHead); if (newHead.Equals(food)) { // 吃到食物尾巴保留蛇变长 GenerateFood(snake, rows, cols); } else { // 没吃到删掉尾巴长度保持不变 snake.RemoveAt(snake.Count - 1); } }逻辑说明移动的核心是先算出 newHead 再统一处理而不是把整条蛇往前挪一格——后者每个格子的赋值都要写成循环自碰撞判断还会变得很麻烦。snake.Contains(newHead) 能直接判断自碰撞是因为 Point 是结构体值相等就返回 true不需要额外实现比较逻辑。吃到食物时不删尾巴蛇的长度自然加一没吃到就删尾这个「头增尾删」的配合就是移动的本质。参数说明dir 是 (1,0)、(-1,0)、(0,1)、(0,-1) 四者之一方向不能反向调头是这类注释里最常强调的规则实现上一般用 Direction 枚举或者在输入段直接拦截反向键。GenerateFood 生成食物时要避开 snake 里所有坐标常见做法是循环随机直到落在空位网格大时改用「维护空位列表」更稳定。3.3 俄罗斯方块旋转一次转置加镜像覆盖全部七个形状俄罗斯方块的七种形状 T、S、Z、L、J、I、O用 int[,] 表示后旋转都是同一套矩阵运算// 顺时针旋转 90 度先转置再把每行左右翻转 static int[,] RotateClockwise(int[,] shape) { int h shape.GetLength(0); int w shape.GetLength(1); int[,] rotated new int[w, h]; for (int y 0; y h; y) for (int x 0; x w; x) rotated[x, h - 1 - y] shape[y, x]; return rotated; }逻辑说明转置是让行列互换镜像是对列号做 h - 1 - y 的翻转两步合起来就是顺时针 90 度。单独给每个形状手写旋转表虽然直观但七份数组又长又容易抄错统一用这个函数I 形方块旋转前后宽高互换的问题也被处理掉了因为返回矩阵的维度是 w×h。参数说明旋转之后必须做边界修正注释详细的源码一般会写一个 CanPlace(shape, x, y) 函数把形状和目标位置传入检查每个值为 1 的格子是否越界或与场景里已固定的方块重叠全部通过才允许落位。很多入门版本省略这一步直接用旋转后的数组覆盖当前位置就会出现「方块转着转着卡进墙里」的经典 bug。遇到这种情况优先检查旋转后有没有执行边界判定。4. 拼图、连连看、五子棋C# 二维数组与连通、胜负判定的实现4.1 拼图洗牌Fisher-Yates 随机打乱与可解性校验拼图项目里最容易踩的坑不是拖动逻辑而是洗牌。直接 Random 随机交换几百次生成出来的排列有约一半是无解的玩家拼到最后两块永远对不上。原因在于拼图移动只交换相邻空格会改变排列的逆序数奇偶性而随机交换可以改变任意两格产生两种奇偶性的排列只有一半能通过相邻交换还原。// 一维数组表示拼图0 代表空格 int[] tiles Enumerable.Range(0, 16).ToArray(); Random rnd new Random(); // Fisher-Yates 洗牌从后往前交换每个排列等概率出现 for (int i tiles.Length - 1; i 0; i--) { int j rnd.Next(i 1); int temp tiles[i]; tiles[i] tiles[j]; tiles[j] temp; }逻辑说明Fisher-Yates 从后往前扫每次把当前位置和前面任意位置交换时间复杂度 O(n)每个排列出现的概率相同。相比「随机交换一千次」的做法它没有重复交换的偏向也不会因为交换次数不够而留下大段有序片段。参数说明洗牌完成后要计算逆序数做可解性校验。统计每个数字前面比它大的数字个数之和对 15 拼图4×4来说逆序数与空格所在行从底部数的行数之和必须是偶数才有解校验不通过时交换数字 1 和 2 再重新计算即可。拼图尺寸不同公式会随之变化注释里通常会标明当前适用的是几乘几的版本。拼图尺寸逆序数规则空格行数是否参与3×3逆序数为偶数即可解否4×4逆序数与空格从底部数的行数之和为偶数是5×4规则同上按列宽决定奇偶是4.2 连连看连通判定直线、一次拐弯、两次拐弯三档处理连连看的核心是判定两个格子能否在不超过两次拐弯的路径上连通。代码组织按拐弯次数分三档正好对应注释里最常见的三段式结构// 判断 a、b 两个点之间能否连通map 中 0 表示空格 bool CanLink(int[,] map, Point a, Point b) { if (map[a.Y, a.X] ! map[b.Y, b.X]) return false; // 图案不同直接排除 // 第一档同一行或同一列直线连通 if ((a.Y b.Y || a.X b.X) LineClear(map, a, b)) return true; // 第二档一次拐弯拐点落在两个对角位置 Point corner1 new Point(a.X, b.Y); Point corner2 new Point(b.X, a.Y); if (IsEmpty(map, corner1) LineClear(map, a, corner1) LineClear(map, corner1, b)) return true; if (IsEmpty(map, corner2) LineClear(map, a, corner2) LineClear(map, corner2, b)) return true; // 第三档两次拐弯在 a 所在行找一个中间空位 for (int y 0; y map.GetLength(0); y) { Point mid1 new Point(a.X, y); Point mid2 new Point(b.X, y); if (IsEmpty(map, mid1) IsEmpty(map, mid2) LineClear(map, a, mid1) LineClear(map, mid1, mid2) LineClear(map, mid2, b)) return true; } // 沿 a 所在列再扫一遍逻辑同上省略循环体 return false; }逻辑说明LineClear 负责检查两点之间不含端点是否全部为空是三个档位共用的基础函数。一次拐弯的路径是「横、竖」两段拐点本身必须为空两次拐弯则把路径拆成「竖、横、竖」三段需要在 a 所在的行和列分别枚举中间拐点。这个实现把「看起来要寻路」的问题降维成了三次直线检测是连连看源码里最值得反复读的部分。参数说明map 里 0 表示空格非 0 表示图案编号。实际项目中有效棋盘外通常再包一圈 0也就是多扩一行和一列空位这样边缘的方块也能走外圈路径。如果发现某些本该连通的点连不上优先检查 LineClear 是否把端点也算进去了以及 b 点是否已经被上方消掉。4.3 五子棋胜负判定方向数组一次扫描四个方向五子棋不需要在每次落子后扫描整个棋盘只需要从刚落下的位置出发沿横、竖、两条斜线四个方向数同色棋子正反两向加起来达到 5 颗即胜。// 四个方向向量横、竖、主对角线、副对角线 int[] dx { 1, 1, 0, 1 }; int[] dy { 0, 1, 1, -1 }; bool CheckWin(int[,] board, int x, int y, int size) { int player board[y, x]; for (int i 0; i 4; i) { int count 1; // 落子点本身算一颗 // 正方向数到底 for (int step 1; step 5; step) { int nx x dx[i] * step; int ny y dy[i] * step; if (nx 0 || nx size || ny 0 || ny size) break; if (board[ny, nx] ! player) break; count; } // 反方向数到底 for (int step 1; step 5; step) { int nx x - dx[i] * step; int ny y - dy[i] * step; if (nx 0 || nx size || ny 0 || ny size) break; if (board[ny, nx] ! player) break; count; } if (count 5) return true; } return false; }逻辑说明dx、dy 两个数组组合出四个方向向量(1,0) 横着数(0,1) 竖着数(1,1) 和 (1,-1) 是两条斜线。每个方向都从落子点出发向两头数边界检查放在取数组下标之前避免越界异常。只检查最后一个落点周围而不是全盘扫描是这段代码性能好的关键。参数说明size 是棋盘边长标准五子棋是 15改成 19 就变成围棋棋盘尺寸其他地方基本不用动。count 5 而不是 5是为了兼容连成六颗以上的情况如果要实现「五子以上不算胜」的禁手规则还需要额外判断两端是否被堵死那是进阶版本才考虑的事。5. 用三处小改动验收六个 C# 小游戏项目的注释读法注释再详细读和懂是两回事。我的习惯是把六个项目按 飞机大战 → 贪吃蛇 → 俄罗斯方块 → 拼图 → 连连看 → 五子棋 的顺序编译一遍每过一关只做一个最小改动用来验证自己是不是真读懂了。项目建议改动改完能验证什么飞机大战刷新间隔从 100ms 改成 40ms是否理解帧率与输入读取的耦合关系贪吃蛇在网格中间加一面竖直障碍墙是否理解碰撞判定的执行顺序俄罗斯方块场景从 10×20 改成 10×24是否找到了所有写死 20 的地方拼图3×3 改成 4×4是否理解逆序数校验公式的适用条件连连看允许三次拐弯是否理解拐点枚举与 LineClear 的组合方式五子棋棋盘从 15×15 改成 12×12是否找到了所有依赖 size 的地方每个改动做完跑一遍完整流程卡住的位置就是理解断点。常见结果有两个改飞机大战帧率后发现按键失灵说明没意识到输入读取和刷新是两个独立的节奏改俄罗斯方块行数后发现消行判定错位说明有地方把场景高度写成了常量而不是引用 rows。这两种现象都是注释里「注意此处不要写死」覆盖过的地方亲自踩一遍才能把这句话变成自己的判断。最后一个技巧是「把注释当断言读」。注释写「如果食物生成在蛇身上重新生成」时就在下一行设断点注释写「旋转后可能越界需要修正」时就在旋转函数返回后设条件断点。让断点停在注释描述的情景里一次比背诵十遍注释都有用。反过来如果某个注释描述的情景怎么都触发不了那这行注释多半已经和代码不同步了这恰恰是二开时最值得修掉的问题。六个项目都过完一遍后把每份源码里所有 Console.Clear() 或 Invalidate() 出现的位置单独列出来你会得到六份绘制入口清单它们各自指向的循环体就是每份代码真正的心脏。本文还有配套的精品资源点击获取