ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#七个小游戏实战:用WinForm和GDI+打通游戏开发核心算法

C#七个小游戏实战:用WinForm和GDI+打通游戏开发核心算法 简介一份面向C#初学者的综合性实战资源聚集飞机大战、俄罗斯方块、贪吃蛇、拼图游戏、连连看、五子棋等七个小游戏项目覆盖游戏循环、碰撞检测、绘图渲染、事件处理等典型主题注释详细便于逐行理解实现逻辑。压缩包共包含1286个文件以cs源码、xml配置、dll依赖、png图片素材及wav/mp3音效为主另有sln及csproj工程文件可直接打开并运行整体约74.9MB。目前已有1284人浏览学习适合刚接触WinForms或基础游戏开发的学习者用于模仿、调试与二次改造。通过阅读这些项目可以理清小游戏的整体架构设计思路掌握菜单切换、分数统计、关卡生成、音效播放等常见模块的编写方式同时借助详细注释快速定位关键代码有效降低自学过程中的踩坑成本。1. 为什么初学者都该过一遍七个小游戏每个游戏补一块 C# 的知识拼图拿到 C#七个小游戏项目飞机大战、俄罗斯方块、贪吃蛇、拼图游戏、连连看、五子棋这套练习源码大多数初学者的第一反应是跑起来玩一局然后对着注释看完一遍过两天又忘得干干净净。我的建议正好反过来这些游戏的价值不在“能跑”而在每一个都逼你动用一个具体的 C# 技能——飞机大战在讲帧循环与碰撞贪吃蛇在讲链式移动和边界判定五子棋在讲二维数组与胜负判定。六个做完课堂上散装的 C# 入门知识会被一条项目线串起来那些“看懂了却写不出来”的问题基本消失。下面这些内容更适合正在找项目练手的初学者以及带课程设计、想给选题把关的指导者看完你能照着重做也知道每一步为什么这么做。2. 先定技术形态为什么是 WinForm GDI而不是控制台或 WPF初学 C# 最容易被问“你用什么做窗体”。用控制台做纯文本交互没法处理动画用 WPF 学事件绑定和样式又太重对你第一个游戏项目来说C# WinForm 是阻力最小的路线。这一章先把形态决定做对后面六个游戏都建立在同一个窗口骨架之上。2.1 控制台方案为什么不适合动作类游戏我见过有人拿控制台做贪吃蛇在 System.Console 里每帧刷新光标位置。效果是蛇在闪用了 Console.Clear 会有整屏白闪不用的话光标移动时的拖影让人犯晕。原因很简单——控制台不是为图形界面设计的它的最小显示单元是字符而不是像素无法做到平滑动画更别提飞机大战和拼图这种像素级操作。所以这里直接下结论动作类和图形界面类小游戏框架选择顺序是 WinForm WPF 控制台。WPF 对初学者不友好之处在绑定体系和样式模板控制台又撑不起画面折中后 WinForm 是唯一能在最小学习成本内兼顾界面、键盘鼠标事件和定时器这三样东西的框架。很多入门的 C# 课程设计也直接默认用 WinForm这也是 C# 窗体应用里最不折腾的一条路。2.2 WinForm GDI 的最小骨架后台画布与防闪烁重绘先抛开具体游戏把六个游戏都依赖的“画布”搭出来。思路是把整个 Form 当作一块画板GameTimer 每隔一段时间推进一次逻辑再把画面画到一块离屏 Bitmap 上最后统一贴到窗口。这个“画到离屏再整体贴上去”的步骤就是解决 GDI 闪屏的关键。打开 Visual Studio 新建一个 Windows 窗体应用.NET Framework 或 .NET 6 都一样把下面这段加进窗体类public partial class MainForm : Form { private System.Windows.Forms.Timer gameTimer; // 游戏主循环定时器 private Bitmap canvas; // 离屏画布所有画面先画在这上面 private Graphics g; // 画布的绘图对象 public MainForm() { InitializeComponent(); canvas new Bitmap(ClientSize.Width, ClientSize.Height); g Graphics.FromImage(canvas); gameTimer new System.Windows.Forms.Timer(); gameTimer.Interval 16; // 约60帧/秒16ms一帧 gameTimer.Tick OnGameTick; gameTimer.Start(); } private void OnGameTick(object sender, EventArgs e) { UpdateLogic(); // 1. 更新位置、碰撞、计分等逻辑 Render(); // 2. 将最新状态画到 canvas Invalidate(); // 3. 请求窗口重绘触发 OnPaint } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); e.Graphics.DrawImage(canvas, 0, 0); // 把离屏画布一次性贴到窗口 } }逻辑说明如果直接在 OnPaint 里逐格画每画一个元素窗口就局部刷新一次屏幕在反复被清掉又重新画闪烁自然跑不掉。先把整个画面画好再一次性提交显示效果才会稳定。UpdateLogic 负责所有“脑力劳动”Render 只负责“画画”这个分离对后面所有游戏都成立。参数说明Interval16 是一个经验值对应 60Hz 刷新率多数显示器上够流畅。如果游戏逻辑较复杂改成 20 也可接受改成小于 10 通常没必要反而让 CPU 空转。窗体默认没有开启双缓冲时在 Form 的构造里把 DoubleBuffered 设为 true 能进一步减少闪烁public MainForm() { // 省略其他初始化 DoubleBuffered true; // 让窗体自身也参与双缓冲 }2.3 游戏循环Timer 并不精确时间步长要自己算System.Windows.Forms.Timer 的 Tick 回调只在消息循环空闲时触发它并不能保证 Interval 到了就准时执行。也就是说如果你把速度参数直接按“Timer 每 16ms 走一格”来写在 CPU 忙的时候蛇会明显变慢。正规做法是和真实时间对齐——用 Stopwatch 量出真实流逝的时间再让游戏逻辑乘以这个时间。把刚才的 OnGameTick 改成这样private Stopwatch stopwatch new Stopwatch(); private double lastTime 0; private void OnGameTick(object sender, EventArgs e) { double now stopwatch.Elapsed.TotalSeconds; double delta now - lastTime; // 真实流逝时间单位秒 lastTime now; UpdateLogic(delta); // 逻辑层拿到 delta 后自行换算 Render(); Invalidate(); }逻辑说明stopwatch 在 Form 构造里调用 start首次进入 OnGameTick 时 now 会远大于 lastTimedelta 很大第一帧会有一次跳变。处理办法是在 Timer.Start 之前先执行一次 lastTime stopwatch.Elapsed.TotalSeconds或者在 delta 超过 0.1 秒时直接丢弃这帧。参数说明delta 的单位是秒游戏里所有速度都用“每秒多少个像素/每秒几格”来表达比如飞机速度 200 像素每秒每帧位移就是 200 × delta。这样即使换了机器、改了帧率游戏速度不变。这个位置还藏着一个初学者经常撞上的坑——C# Timer 访问控件。System.Windows.Forms.Timer 的 Tick 就在 UI 线程上执行直接改控件没问题真正翻车的是改成 System.Timers.Timer它的回调跑到线程池去了就得用 Invoke 回到 UI 线程才能改控件。后面避坑章节会展开。3. 把六个游戏拆成六种核心算法飞机大战到五子棋的代码骨架六个游戏看起来差异巨大拆开看其实是六组不同的算法主题。从容器角度看List 这类泛型集合几乎贯穿所有游戏——子弹要 List 管理、蛇身要 List 维护、拼图编号要交错存放。每一组只给最小可用的核心片段不贴完整窗体代码你拿着这些片段组合进上一章的骨架就能跑。重点看每段代码背后的判断逻辑这是整个项目里最值得反复读的部分。3.1 飞机大战对象池复用子弹与矩形碰撞检测飞机大战里最直观的问题是子弹。你每按一次空格 new 一发子弹多按几次 GC 回收就开始忙碌帧率随之波动。用对象池来解决维护一个 List 需要发射时先找有没有已经“去世”的子弹有就重新激活没有才新建。private ListBullet bullets new ListBullet(); // 子弹对象池 private readonly ListEnemy enemies new ListEnemy(); // 敌机列表 private void Fire() { Bullet b bullets.Find(x !x.Active); // 找一颗可复用的“死弹” if (b null) { b new Bullet(); bullets.Add(b); } b.Active true; b.X player.X player.Width / 2 - b.Width / 2; b.Y player.Y - b.Height; } private void CheckCollision() { foreach (var enemy in enemies) { Rectangle er new Rectangle(enemy.X, enemy.Y, enemy.Width, enemy.Height); foreach (var b in bullets) { if (!b.Active) continue; Rectangle br new Rectangle(b.X, b.Y, b.Width, b.Height); if (br.IntersectsWith(er)) // 矩形相交即视为命中 { b.Active false; // 子弹回收不销毁留待复用 enemy.HP--; if (enemy.HP 0) { enemy.Active false; score 10; } } } } }逻辑说明Rectangle.IntersectsWith 是 GDI 矩形碰撞的标准做法把所有飞机、子弹都抽象成矩形两个矩形相交判为命中。子弹“死亡”只改 Active 标志不让它被垃圾回收这就是对象池的核心思想——减少 new 和释放保持内存稳定。参数说明敌机血量 enemy.HP 建议放在 Enemy 类里而不是外部判断每个敌机类型小飞机 1 血、中型 3 血、Boss 10 血在自己的类里控制。Find 每帧执行有微小开销但比反复 new 对象仍然合算想更极致可以换队列实现初学者阶段 List 足够。按键连发的处理这里也一并给掉KeyDown 事件在按住时会多次触发不能用它直接开火改成记录按键状态在 UpdateLogic 里每帧检查一次。private bool spaceDown false; // 由 KeyDown/KeyUp 维护 private void HandleInput() { if (!spaceDown) return; if (DateTime.Now - lastFireTime TimeSpan.FromMilliseconds(150)) return; Fire(); lastFireTime DateTime.Now; // 控制射速避免按住空格变成机关枪 }3.2 俄罗斯方块二维数组旋转与整行消除俄罗斯方块的核心难点是“旋转”。把每个方块形状存成 int[,] 矩阵1 表示有格子0 表示空。顺时针旋转的数学做法是新矩阵的第 i 行取原矩阵第 i 列倒序。代码很简短但是这一段是大量新人写错的地方。// shape[r, c] 顺时针旋转成 rotated private int[,] RotateClockwise(int[,] shape) { int rows shape.GetLength(0); int cols shape.GetLength(1); int[,] rotated new int[cols, rows]; // 注意行列交换 for (int r 0; r rows; r) { for (int c 0; c cols; c) { rotated[c, rows - 1 - r] shape[r, c]; } } return rotated; }逻辑说明旋转后新的宽高是反过来的所以 rotated 先按 cols × rows 申请。为什么是 rows - 1 - r因为顺时针旋转时原矩阵的最上面一行变成了新矩阵最右一列这个索引关系多算几次就能固化成模板。如果只用 L 型测试容易对换 S 型就往左偏那是因为没有处理墙踢这一点在第 5 章避坑里专门讲。行消除同样需要二维数组从最后一行往上扫满行就整体往下移一行private int ClearLines(int[,] board) { int cleared 0; for (int r board.GetLength(0) - 1; r 0; r--) { bool full true; for (int c 0; c board.GetLength(1); c) { if (board[r, c] 0) { full false; break; } } if (full) { // 把 r 以上的所有行向下移一行 for (int rr r; rr 0; rr--) for (int c 0; c board.GetLength(1); c) board[rr, c] board[rr - 1, c]; for (int c 0; c board.GetLength(1); c) board[0, c] 0; // 顶部补空行 r; // 消除后重新检查当前行防止连续满行漏判 cleared; } } return cleared; }逻辑说明消除满行后上面所有行下移一格顶部补一行空白。r 让循环重新检查原来的位置避免“一行消掉后上面还有满行”时漏判。board 是 10 × 20 的标准俄罗斯方块棋盘0 表示空格非 0 表示已填充。参数说明旋转前先试算偏移如果转完的矩阵越界就左右平移再检查仍然越界才放弃旋转。这个墙踢逻辑是手感的关键很多老版本出现方块卡进墙里就是因为没做墙踢。步数和速度要用分数递增去拉动别在一个固定 Interval 上硬撑。3.3 贪吃蛇链式移动、食物生成与 C# List 移除的坑贪吃蛇的蛇身可以抽象为一个 Point 的 List头在 List[0]尾在 List 末尾。每次移动往头部插入新坐标再移除尾巴坐标。如果吃到食物就不移除尾巴长度自然增加一格。private ListPoint snake new ListPoint(); private void MoveSnake(Point dir) { Point head snake[0]; Point newHead new Point(head.X dir.X, head.Y dir.Y); if (newHead.X 0 || newHead.X cols || newHead.Y 0 || newHead.Y rows) { GameOver(); return; } if (newHead food) { snake.Insert(0, newHead); // 吃到食物头部插入尾巴不动 score 10; GenerateFood(); } else { snake.Insert(0, newHead); // 普通移动头进 snake.RemoveAt(snake.Count - 1); // 尾出 } }逻辑说明Insert(0, newHead) 的时间复杂度是 O(n)因为 List 内部要往后移动所有元素。蛇身最长不过几百节这个开销可以忽略。真正要小心的是用 RemoveAt(snake.Count - 1) 而不是 Remove(head)Remove 按值查找会先遍历一整圈再删蛇长接近满屏时每帧多几十次比较没必要。参数说明direction 用 Point 表示位移四种方向分别是 (0, -1)、(0, 1)、(-1, 0)、(1, 0)。注意不能和当前移动方向反向否则蛇头直接撞身体。拦截要在方向切换时做不然会有“按了向下结果立刻死亡”的体验问题。食物生成要用 Random 排除蛇身占领的格子否则可能在蛇身体里生成永远吃不到的食物GenerateFood 里用 do-while 循环保证位置合法。3.4 拼图游戏图片切割、网格坐标与相邻交换拼图游戏的技术点是“把一张图片切成 N × M 块再把这些块当作排列问题处理”。切图最简单的方式是用 Bitmap.Clone(Rectangle)不需要画布去慢慢画。然后维护一个二维数组0 代表空白块其他数字代表第几块。private Bitmap[,] SplitImage(Image source, int rows, int cols) { Bitmap[,] tiles new Bitmap[rows, cols]; int cellW source.Width / cols; int cellH source.Height / rows; for (int r 0; r rows; r) { for (int c 0; c cols; c) { Rectangle rec new Rectangle(c * cellW, r * cellH, cellW, cellH); tiles[r, c] new Bitmap(source).Clone(rec, source.PixelFormat); } } return tiles; }逻辑说明切图不是游戏逻辑在初始化时执行一次即可。每个格子的编号 grid[r, c] 单独维护与 tiles[r, c] 一一对应。用户点击的是像素坐标先转成网格坐标再判断点中的格子是否在空白格旁曼哈顿距离为 1是则交换。胜利条件是编号全部归位// 像素坐标转网格坐标 private Point ToGrid(Point pixel, int cellW, int cellH) { return new Point(pixel.X / cellW, pixel.Y / cellH); } // 判断可移动空白格与目标格相邻 private bool IsAdjacent(Point blank, Point target) { return Math.Abs(blank.X - target.X) Math.Abs(blank.Y - target.Y) 1; } // 交换两个格的编号 private void Swap(ref int a, ref int b) { int temp a; a b; b temp; } private bool IsSolved(int[,] grid, int rows, int cols) { for (int r 0; r rows; r) for (int c 0; c cols; c) if (grid[r, c] ! r * cols c 1) // 编号按行优先 return false; return true; }逻辑说明这里有三个容易混的点。第一切图得到的 tiles 是不需要动的真正变化的是 grid 编号之后每次重绘时按 grid[r, c] 去 tiles 里找对应的图块来画。第二shuffle 时不能纯随机打乱编号要保证有解。第三胜利判定条件用“编号 行 × 列 1”0 号留作空白那空白格永远在最后这是经典拼图游戏的标准约束。参数说明cols × rows 建议 3 × 3 或 4 × 4再大会切出很多零碎图块不利于判断。cellW、cellH 是像素尺寸注意整除问题——图片随便裁切会导致最后一个格子越界最好先在初始化时把原始图缩放成 cols * cellW × rows * cellH 的准确尺寸再切。3.5 连连看路径搜索与两次拐弯可达性连连看是六个游戏里算法最重的一个。两个图案相同的格子要求用不超过两次拐弯的折线连起来并且连线路径上不能经过其他图案。做法可以拆成两层先写一个判断“直线上无障碍”的函数再穷举一次拐弯和两次拐弯的路径。// 直线无障碍a、b 必须在同一行或同一列且中间不含其他图案 private bool StraightClear(Point a, Point b, int[,] board) { if (a.Y b.Y) { int min Math.Min(a.X, b.X); int max Math.Max(a.X, b.X); for (int x min 1; x max; x) // 不含 a、b 本身 if (board[a.Y, x] ! 0) return false; return true; } if (a.X b.X) { int min Math.Min(a.Y, b.Y); int max Math.Max(a.Y, b.Y); for (int y min 1; y max; y) if (board[y, a.X] ! 0) return false; return true; } return false; } // 能否在两次拐弯内连接 private bool CanLink(Point a, Point b, int[,] board) { if (a b) return false; int rows board.GetLength(0); int cols board.GetLength(1); if (a.Y b.Y StraightClear(a, b, board)) return true; if (a.X b.X StraightClear(a, b, board)) return true; // 一次拐弯拐点是矩形的另外两个顶点 if (board[a.Y, b.X] 0 StraightClear(a, new Point(a.X, b.Y), board) StraightClear(new Point(a.X, b.Y), b, board)) return true; if (board[b.Y, a.X] 0 StraightClear(a, new Point(b.X, a.Y), board) StraightClear(new Point(b.X, a.Y), b, board)) return true; // 两次拐弯先沿 a 所在行向右找一条空竖线再过竖线到 b for (int x 0; x cols; x) { if (x a.X) continue; if (board[a.Y, x] ! 0) continue; if (board[b.Y, x] ! 0) continue; Point va new Point(x, a.Y); Point vb new Point(x, b.Y); if (StraightClear(a, va, board) StraightClear(va, vb, board) StraightClear(vb, b, board)) return true; } // 同理沿列方向再扫一遍 for (int y 0; y rows; y) { if (y a.Y) continue; if (board[y, a.X] ! 0) continue; if (board[y, b.X] ! 0) continue; Point ha new Point(a.X, y); Point hb new Point(b.X, y); if (StraightClear(a, ha, board) StraightClear(ha, hb, board) StraightClear(hb, b, board)) return true; } return false; }逻辑说明两次拐弯的扫描思路是——找一个空的竖线 x让 a 先从自己那行横走到竖线上再沿竖线上下走到 b 那一行最后横走到 b。三段路径各做一次直线检查。这段代码是“枚举式”实现只处理了标准棋盘内的情况经典连连看还允许连线绕过棋盘外侧办法是在棋盘外圈补一圈空行让 StraightClear 的索引边界天然放行。参数说明board 是网格0 表示空格非 0 表示图案编号。点击两个格子后先判断图案相同且都不是 0再调 CanLink。这里的坐标都是网格坐标调用前要用像素坐标除以格宽、格高完成换算。棋盘外围补空行的做法建议直接写在初始化里否则新手最容易在这个边界判定上误判。3.6 五子棋二维数组建模、胜负判定与最小 AI五子棋棋盘用 15 × 15 的 int[,]0 空、1 黑、2 白。胜负判定不需要每帧全盘扫描只需要在落子后沿四个方向横、竖、主对角线、副对角线数连子正向反向加起来等于 5 就赢。private bool CheckWin(int[,] board, int x, int y) { int player board[y, x]; int[] dx { 1, 0, 1, 1 }; int[] dy { 0, 1, 1, -1 }; for (int d 0; d 4; d) { int count 1; // 当前落子自己算一个 count CountDir(board, x, y, dx[d], dy[d], player); count CountDir(board, x, y, -dx[d], -dy[d], player); if (count 5) return true; } return false; } private int CountDir(int[,] board, int x, int y, int dx, int dy, int player) { int count 0; for (int step 1; step 5; step) { int nx x dx * step; int ny y dy * step; if (nx 0 || nx 15 || ny 0 || ny 15) break; if (board[ny, nx] player) count; else break; } return count; }逻辑说明四个方向的 (dx, dy) 数组是五子棋胜负判定的经典模板分别代表水平、垂直、两条对角线。每个方向先向左数再向右数合起来正好覆盖整条线。步数上限设成 4是因为只要连够 5 颗就能赢再往后即使连子更多也仍然算赢上限和边界判断省掉了大量分支。AI 方面初学者不需要做 alpha-beta 剪枝这种大工程。一个“打分贪心”已经足够和同学对战遍历每个空位分别假设黑子和白子落在那里统计四个方向上形成的连子长度按长度给分取总分最高的点落子。private Point GetBestMove(int[,] board, int aiPlayer) { int bestScore 0; Point best new Point(7, 7); for (int y 0; y 15; y) { for (int x 0; x 15; x) { if (board[y, x] ! 0) continue; int score EvaluatePoint(board, x, y, aiPlayer); // 进攻分 score EvaluatePoint(board, x, y, 3 - aiPlayer); // 防守分 if (score bestScore) { bestScore score; best new Point(x, y); } } } return best; }逻辑说明EvaluatePoint 在四个方向上数“若在该位置落子能连出几个子”并给活四、冲四、活三这些棋形不同的权重。把进攻分和防守分加起来AI 就会一边扩张自己的棋形一边堵你的棋形。进阶方向是递归搜索下一步的变化那等价于开始写真正的博弈算法了此处不展开。4. 工程怎么组织七个游戏共用一个窗体骨架学会了每个游戏的核心算法下一个自然的问题是整套源码怎么放才不至于复制了六个窗体却改一个 bug 要改六遍“七个游戏一个项目”是课程设计常见的组织方式但要让它成为能维护的工程必须做一点分层和复用。4.1 先给工程分层界面、逻辑、数据三者分离常见做法是把每个游戏都写成独立的 Form 类再把所有游戏塞进一个 TabControl 或菜单切来切去。问题在于六个窗体里都复制了一份 Timer 和画布代码当你想统一修改帧率或暂停逻辑就得挨个改。把共性的东西抽出来建立三层结构第一层是基类 GameBase管理 Timer、画布、游戏状态第二层是每个游戏的实现类只写自己的 Update 和 Render第三层是数据类比如飞机大战的 Bullet、俄罗斯方块的 Block、贪吃蛇的 SnakeNode它们只存数据不带界面逻辑。GameProject/ ├─ MainForm.cs # 菜单/选项卡负责切换游戏 ├─ Games/ │ ├─ GameBase.cs # 所有游戏共享的基类 │ ├─ PlaneWarGame.cs │ ├─ TetrisGame.cs │ ├─ SnakeGame.cs │ ├─ PuzzleGame.cs │ ├─ LinkLinkGame.cs │ └─ GobangGame.cs └─ Models/ ├─ Bullet.cs ├─ Block.cs ├─ SnakeNode.cs └─ Tile.cs参数说明把 MainForm 做成 TabControl每个 Tab 页里放一个 PictureBox游戏就画到各自的 PictureBox 上。这样不需要在多个窗体之间切换焦点键盘事件也更好管理——用一个窗体的 KeyPreview 统一处理按键再转发给当前 Tab 对应的游戏实例。切换 Tab 时还有一个隐藏坑上一个游戏的 Timer 还在跑。处理办法是 MainForm 里记录当前 ActiveGame切换时先 Stop 再 Start避免六个游戏同时在后台跑否则你会看到两个游戏叠加在同一个 PictureBox 上。4.2 公共基类 GameBase把循环、暂停、计分做成通用能力写基类的好处是把六个游戏公共的骨架放在一个地方。基类里定义好抽象的 Update 和 Render子类只需要实现这两个方法public abstract class GameBase { protected Timer gameTimer; // 公共定时器 protected Bitmap canvas; // 离屏画布 protected Graphics gfx; protected int score; protected bool isPaused; protected GameBase(int interval 16) { canvas new Bitmap(800, 600); gfx Graphics.FromImage(canvas); gameTimer new Timer { Interval interval }; gameTimer.Tick (s, e) { if (!isPaused) Update(0.016); // 固定步长或传入真实 delta Render(); }; } public void Start() gameTimer.Start(); public void Pause() isPaused true; public void Resume() isPaused false; public void Stop() gameTimer.Stop(); protected abstract void Update(double delta); protected abstract void Render(); }逻辑说明子类构造函数先调 base(interval)并把自己需要的数据结构初始化好。Update 和 Render 在每个游戏里各自实现。暂停和计分都放在基类意味着你可以在主窗体做一个统一的暂停按钮按一下就调用当前游戏实例的 Pause而不是每个游戏各写一个暂停逻辑。Render 方法里最好在开头用 gfx.Clear(Color.White) 清一次画布防止上一次残留画面叠在新画面上这也是初学者反复遇到的黑匣子问题画面好像被“烙”在窗口上。4.3 接口还是继承不同输入方式用接口补齐六个游戏里飞机大战和贪吃蛇主要用键盘拼图、连连看和五子棋主要用鼠标俄罗斯方块两个都用。为了不在基类里硬编码输入事件定义两个接口让需要用到的游戏去实现public interface IKeyboardHandler { void OnKeyDown(Keys key); } public interface IMouseHandler { void OnMouseClick(Point location); }主窗体检测到按键或鼠标点击时判断当前游戏是否实现了对应接口是就调用它的方法不是就忽略。这样基类保持干净不同游戏按需扩能力。对初学者来说接口的存在会让你更清楚一个游戏“支持什么输入、不支持什么输入”在拆解源码时少绕弯子这也是后续给游戏加新功能不会把基类改坏的关键。5. 避坑初学 C# 小游戏最容易翻车的 5 个细节每个几乎都是课程设计里反复出现的问题按“现象 → 原因 → 解决”写清楚省得看注释时以为懂了跑起来全是 bug。5.1 现象按一下方向键蛇或飞机猛地跳一大格原因KeyDown 事件在按住时会连续触发如果移动逻辑直接写在 KeyDown 里一次按键会累积十几次位移角色自然滑出去很远。这是初学者最容易先踩的输入处理坑。解决把输入与逻辑分离。KeyDown 和 KeyUp 只负责维护一个“按键状态集合”HashSet 在 Update 里查询集合来决定方向。每次 Tick 只读一次按键状态再结合真实时间 delta 计算位移按住键就会变成持续平滑移动而不是跳格。5.2 现象贪吃蛇吃到食物后分数加了但蛇身没有增加原因Insert 和 RemoveAt 的先后顺序写反了。正确逻辑是先算蛇头新位置吃到食物时 Insert 头且不 Remove 尾没吃到时才同时 Insert 头和 Remove 尾。有初学者在吃到食物分支里也执行了 RemoveAt尾巴被截掉长度自然不变。解决把“蛇头新位置”的 Point 先算出来再按是否吃到食物分成两个分支两条分支都 Insert 头是否 Remove 尾由食物分支决定。逻辑混在一起时先拆开再合并相同步骤。5.3 现象俄罗斯方块旋转时方块直接嵌进墙里出不来原因旋转只改了矩阵没检查和修正位置。旋转后矩阵的行列数变了方块靠墙时大概率越界。解决旋转后做位置修正给 x 做三次尝试——保持当前位置、左移一格、右移一格取第一个不越界的坐标。如果三次都越界就放弃旋转。这段逻辑每个方格游戏都要写一遍是真正的“墙踢”别偷懒。修正完再落盘到棋盘上顺序是旋转 → 修正 → 落盘。5.4 现象拼图 shuffle 后总有两块拼不回来原因随机打乱编号数组会产生大量无解排列。拼图游戏的可解性由“逆序数 空白行号”的奇偶性决定纯 Random 打乱不保证有解。解决用“若干次合法移动”来打乱。从完成状态开始随机让空白格和上下左右的邻居交换几百次得到的一定是有解排列。打乱次数越多越难复原初学者可以先用 100 次玩熟后再提高难度。5.5 现象自己机器上图片正常拷给同学就全部白屏原因用了绝对路径加载图片D:\Game\img\1.png换机器自然读不到。GDI 里加载不存在的路径不会立刻报错只会画一个空白矩形因此这类错误特别隐蔽。解决把图片加到项目资源或统一放到 Content 目录运行时用 AppDomain.CurrentDomain.BaseDirectory 拼接相对路径。拷项目时整个文件夹一起交出去不要只拷 exe。排查时先打印一次图片是否加载成功能省下很多瞎猜时间。6. 验证与进阶怎么证明你能把这套代码讲明白6.1 亲手写出第七个游戏打地鼠的最小实现标题写着七个小游戏却只列了六个第七个是留给你自己的作业。写一个打地鼠大概只需要三样东西一个负责随机出现目标的 Timer、一张把像素坐标换算成网格的判定、一个计分器。代码量不过几十行却能把前面六个游戏里出现的对象池、网格坐标、输入处理、计时循环全部复用一遍。用同一个 GameBase 基类去写比读十遍注释更能暴露你哪里没懂——设计新游戏时第一步不是查语法而是决定它继承基类还是实现接口、命中规则放 Update 还是放 Render这些决策才说明你真正掌握了整个项目结构。6.2 吃透项目的三个自测问题每完成一个游戏就自问三句游戏循环的 Timer 回调跑在哪个线程为什么可以直接操作控件这个游戏的二维数组是做什么的可不可以替换成 List如果把它移植到 UnityUpdate 和 Render 哪些能带走、哪些要重写。能答清楚这三句这个项目的价值就算真正拿到了。剩下的是把注释里没讲透的细节转成你自己的踩坑笔记。我个人的习惯是每个游戏源码最前面留一段“卡点注释”写当时在哪里翻车、最后怎么解决的。回翻的时候那几行比什么教程都管用。希望帮到你。本文还有配套的精品资源点击获取
返回列表