尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用C#手写WinForms贪吃蛇,串起核心编程知识点

用C#手写WinForms贪吃蛇,串起核心编程知识点 简介这是一套面向初学者的C#控制台贪吃蛇实战项目围绕类、方法、变量、条件语句等核心语法帮助开发者系统练习面向对象设计与控制台交互开发。项目中完整实现了蛇的移动、转向、吃食物增长、撞墙及自撞失败判定等核心逻辑包含游戏主循环、状态机更新、碰撞检测和随机食物生成等关键环节同时用字符绘制游戏画面覆盖Console输入输出、数组/列表存储蛇身、循环与条件判断的综合运用。压缩包共33个文件以.cs源码为主另有可执行的.exe程序、项目配置、缓存及说明文档整体仅70KB轻量易用已有217人学习下载。拿到后可直接阅读源码结构、运行体验也可参考场景划分与类设计思路用来对比和优化自己的贪吃蛇实现项目将流程拆分为蛇身管理、地图绘制、食物生成和场景切换等模块对培养良好编码习惯很有帮助。 很多学C#的朋友都问过我同一个问题语法书翻完了接下来练点什么好我给出的答案一直很固执——写个贪吃蛇。先别急着嫌它简单这个小游戏看起来不起眼却能把C#这门语言里最常用的核心知识点一次性串起来类与对象、泛型集合、枚举、事件委托还有定时器和键盘输入。更关键的是它小到周末就能写完又有足够的结构复杂度能让你切身体会什么叫“界面与逻辑分离”这个思想在你以后做上位机、扫码枪程序、桌面工具时完全是同一个套路。这篇文章就把我完整实现一个Windows Forms版贪吃蛇的过程拆开来讲从类设计到碰撞检测再到实战坑点适合刚学完C#基础、想找个有含金量的练手项目或者准备面试前临时抱佛脚的朋友照着做一遍你的代码组织能力会明显不一样。1. 为什么是贪吃蛇项目价值与整体设计思路1.1 一个小游戏能练到哪些C#核心技能很多初学者容易陷入一个误区总觉得项目越大越能锻炼能力。其实对一个还没写过完整程序的C#新手来说一上来就是三层架构加数据库只会被一堆概念吓退。贪吃蛇这个项目巧妙就巧妙在它麻雀虽小但五脏俱全几乎覆盖了日常开发八成的高频知识点。面向对象设计你需要抽象出Snake蛇、Food食物、Game游戏主逻辑三个类这正是练习职责划分的最佳粒度。泛型集合蛇的身体天然就是一个ListPoint你会真正用到Add、RemoveAt、Contains这些方法而不是在教程里干看。枚举方向键映射成 Direction 枚举配合C#9以后的 switch 表达式代码干净又优雅。事件与委托得分变化、游戏结束都要通知UI刷新这正是事件机制最自然的应用场景。定时器与游戏循环每个游戏都有一个“心跳”Timer就是最简单直接的心跳实现。说白了只要把这个项目从头到尾写明白你的C#基础语法就算扎扎实实过了一遍。而且面试的时候面试官如果问“说说你做过什么”你能把这个项目的类设计讲清楚比背十道面试题都管用。1.2 技术选型为什么用 WinForms做贪吃蛇其实有好几条路控制台程序、Unity游戏引擎、WPF还有WinForms。我为什么建议用WinForms不推荐控制台是因为控制台模式下绘制动画非常别扭你得不停地清屏重绘还要自己处理光标位置精力全耗在输出排版上完全偏离了“练习C#核心”的初衷。不推荐Unity是因为Unity的组件体系、场景管理这些概念会把语言本身的学习复杂度盖过去你学了半天记住的是引擎API而不是C#语法。不推荐WPF虽然WPF很强大但XAML、绑定、数据模板这些概念对新手来说学习曲线太陡很容易劝退。WinForms的优势在于它足够简单又足够贴近实际的桌面业务开发。你拖一个Panel当画布挂一个Timer做心跳键盘事件直接绑在窗体上所有UI交互都是最直观的消息驱动。更重要的是WinForms让我们可以很自然地做到“界面与逻辑分离”逻辑层Game、Snake、Food类不引用任何控件完全独立。界面层MainForm只负责绘制画面、转发按键、显示分数。这样设计最大的好处是以后你想把游戏换成WPF版或者Unity版逻辑层代码一行都不用改。这个思想在做C#上位机的时候尤其重要——串口通讯逻辑和界面显示逻辑必须分离不然项目一复杂就全乱套了。2. 核心类设计与解耦思路2.1 从数据模型出发Point、Snake、Food我写代码的习惯是不管界面长什么样先把数据模型想清楚。贪吃蛇的数据模型其实特别简单核心就三个东西坐标点、蛇、食物。首先是一个坐标点Point。这里我直接用C# 9的record struct自带值相等比较省去冗余代码public record struct Point(int X, int Y); public enum Direction { Up, Down, Left, Right }然后是蛇Snake。蛇的本质就是一个坐标点列表头部是列表第一个元素尾巴是最后一个元素。构造函数里生成初始长度三格的蛇朝右移动public class Snake { public ListPoint Body { get; private set; } public Direction CurrentDirection { get; set; } public Snake(Point start, int initialLength 3) { Body new ListPoint(); for (var i 0; i initialLength; i) { Body.Add(new Point(start.X - i, start.Y)); } CurrentDirection Direction.Right; } public Point Move(Direction direction) { return direction switch { Direction.Up new Point(Body[0].X, Body[0].Y - 1), Direction.Down new Point(Body[0].X, Body[0].Y 1), Direction.Left new Point(Body[0].X - 1, Body[0].Y), Direction.Right new Point(Body[0].X 1, Body[0].Y), _ Body[0] }; } }最后是食物Food。食物更简单本质上就是一个Position属性但我会把“随机生成不撞蛇身”的逻辑封装到这个类里让Game主类保持清爽。你看这三个类型一出来整个游戏的数据结构就清楚了接下来所有逻辑都是围绕怎么操作这份数据展开的。2.2 游戏主类 Game把逻辑从 UI 里拔出来Game类是核心中的核心它负责管理整个游戏的状态流转包括开始、暂停、结束、移动、碰撞检测、得分还有生成食物。这里有一条铁律Game类里绝对不出现任何控件引用不碰Panel、不碰Timer甚至不碰Form。它只通过事件对外广播“得分变了”“游戏结束了”UI层收到通知后自己更新界面。public class Game { public const int GridWidth 20; public const int GridHeight 20; private readonly Snake _snake; private readonly Random _random new(); private Food _food; private readonly QueueDirection _directionQueue new(); private int _speedLevel 1; public Point FoodPosition _food.Position; public IReadOnlyListPoint SnakeBody _snake.Body; public int Score { get; private set; } public bool IsRunning { get; private set; } public event EventHandlerint ScoreChanged; public event EventHandler GameOver; public Game() { _snake new Snake(new Point(GridWidth / 2, GridHeight / 2)); GenerateFood(); } public void QueueDirection(Direction direction) { _directionQueue.Enqueue(direction); } public void Start() { IsRunning true; } public void Pause() { IsRunning false; } public void Step() { if (!IsRunning) return; // 消费方向队列防止同帧内多次按键导致反向 if (_directionQueue.Count 0) { var next _directionQueue.Dequeue(); // 不允许掉头 if (!IsOpposite(next, _snake.CurrentDirection)) { _snake.CurrentDirection next; } } var newHead _snake.Move(_snake.CurrentDirection); // 撞墙检测 if (newHead.X 0 || newHead.X GridWidth || newHead.Y 0 || newHead.Y GridHeight) { EndGame(); return; } // 头部先插入新位置 _snake.Body.Insert(0, newHead); if (newHead _food.Position) { Score 10; ScoreChanged?.Invoke(this, Score); GenerateFood(); // 吃到食物时尾部不删除长度自然增加1 } else { _snake.Body.RemoveAt(_snake.Body.Count - 1); } // 撞自己检测跳过头部其余身体坐标与头部相同即碰撞 if (_snake.Body.Skip(1).Contains(newHead)) { EndGame(); } } private void EndGame() { IsRunning false; GameOver?.Invoke(this, EventArgs.Empty); } private void GenerateFood() { while (true) { var candidate new Point(_random.Next(GridWidth), _random.Next(GridHeight)); if (!_snake.Body.Contains(candidate)) { _food new Food(candidate); return; } } } private static bool IsOpposite(Direction a, Direction b) { return (a Direction.Up b Direction.Down) || (a Direction.Down b Direction.Up) || (a Direction.Left b Direction.Right) || (a Direction.Right b Direction.Left); } }注意看Step()方法里的顺序先取方向再计算新头部然后撞墙检测接着插入头部判断是否吃到食物最后撞自己检测。这个顺序不能乱尤其“先插入头部再判断撞自己”这一点很关键因为新头部必须已经在身体列表里Contains才能判断出重叠。3. 实操过程与核心环节实现3.1 搭建界面与游戏循环项目结构上我建议建一个WinForms项目直接在Form1里加一个Panel作为画布加一个Label显示分数窗体属性KeyPreview设为true这样才能确保键盘事件先被窗体捕获而不是被焦点控件吞掉。游戏循环我用的是System.Windows.Forms.Timer原因很简单它的Tick事件在UI线程触发可以直接更新界面不用考虑跨线程问题。这个特性在你以后做串口上位机时会深有体会——工作线程里收到的数据想更新界面必须Invoke回UI线程非常麻烦而WinForms Timer天然避开了这个坑。public partial class MainForm : Form { private readonly Game _game new(); private readonly Timer _timer; private const int CellSize 20; public MainForm() { InitializeComponent(); gamePanel.DoubleBuffered true; _timer new Timer { Interval 220 }; _timer.Tick (_, _) { _game.Step(); gamePanel.Invalidate(); }; _game.ScoreChanged (_, score) lblScore.Text $得分: {score}; _game.GameOver (_, _) lblState.Text 游戏结束! 按空格重新开始; } protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { switch (keyData) { case Keys.Up: _game.QueueDirection(Direction.Up); return true; case Keys.Down: _game.QueueDirection(Direction.Down); return true; case Keys.Left: _game.QueueDirection(Direction.Left); return true; case Keys.Right: _game.QueueDirection(Direction.Right); return true; case Keys.Space: if (!_game.IsRunning) { _game new Game(); lblScore.Text 得分: 0; lblState.Text ; } _game.Start(); _timer.Start(); return true; } return base.ProcessCmdKey(ref msg, keyData); } }这里要重点说一个新手最容易踩的坑直接用KeyDown事件捕获方向键是失败的。因为方向键在WinForms里默认被当作对话框导航键处理焦点控件会优先消费这个按键事件。最干净的办法是重写ProcessCmdKey它能在消息分发的最早阶段拦下按键不管哪个控件有焦点都能生效。3.2 移动、吃食物与碰撞检测的精髓Step()方法里几个细节值得展开讲讲。移动逻辑其实是“头进尾出”的队列操作每次先把新头部Insert到列表最前面如果没吃到食物就把尾部RemoveAt掉整体长度不变蛇就朝当前方向前进了一格如果吃到了食物尾部不删除长度加一。这个思路和现实中的蛇完全一致——吃东西长身体不吃东西保持原有长度移动。方向处理的精髓在于方向队列。为什么不用一个字段直接存当前方向因为如果你在极短的时间内连续按了“上”再按“左”实际效果可能变成反方向撞死自己。方向队列会把两次按键都记录下来每次Step()只消费一个既不丢失操作又能防止同帧内多次改变方向导致误判。碰撞检测分两种情况。撞墙检测好理解新头部坐标超出网格边界就结束。撞自己要注意一个细节用_snake.Body.Skip(1).Contains(newHead)跳过头部判断剩余节点中是否存在与新头部相同坐标的节点。为什么不直接Body.Contains(newHead)因为新头部已经被Insert进列表了不跳过第一个元素就永远算撞到自己游戏一开局就结束那代码就白写了。3.3 绘制与视觉细节绘制逻辑写在Panel的Paint事件里。核心思路是逻辑坐标全是格子数绘制坐标要把格子数乘以CellSize换算成像素。比如食物坐标为(5, 3)它实际绘制位置就是x5*20100, y3*2060绘制一个20x20的矩形。private void gamePanel_Paint(object sender, PaintEventArgs e) { var g e.Graphics; g.Clear(Color.FromArgb(20, 20, 20)); // 画食物 var foodRect new Rectangle( _game.FoodPosition.X * CellSize, _game.FoodPosition.Y * CellSize, CellSize, CellSize); g.FillEllipse(Brushes.OrangeRed, foodRect); // 画蛇身 for (var i 0; i _game.SnakeBody.Count; i) { var p _game.SnakeBody[i]; var rect new Rectangle( p.X * CellSize 1, p.Y * CellSize 1, CellSize - 2, CellSize - 2); g.FillRectangle(i 0 ? Brushes.YellowGreen : Brushes.Green, rect); } }绘图这里有一个性能细节gamePanel.DoubleBuffered true。这个属性一定要设不然蛇身和食物在快速移动时会出现明显的闪烁。原因是WinForms内建控件默认没有双缓冲每次Invalidate触发重绘时背景擦除和前景绘制之间有间隙肉眼看起来就是闪。开启双缓冲后所有绘制先生成在内存画布上再一次拷贝到屏幕闪烁就消失了。这条经验在做任何WinForms绘图程序时都通用。3.4 随机食物与难度控制生成食物的GenerateFood()用了死循环随机产生一个坐标如果落在蛇身上就重新随机直到找到空闲格子为止。理论上存在极端情况——蛇身占满整个网格导致死循环但实际操作中这几乎不可能发生因为20x20网格有400个格子等蛇长到那么大游戏早就该赢了。你可以在后面加一个判断当分数达到一定值宣告胜利也算是给游戏加个最终结局。难度控制我建议用速度档位而不是线性加速private void UpdateSpeed() { var newInterval Math.Max(70, 220 - (Score / 50) * 15); _timer.Interval newInterval; }每得50分定时器间隔减少15毫秒下限控制在70毫秒。为什么不设得更快因为WinForms Timer基于消息循环本身精度就有几十毫秒的波动调到50毫秒以下会出现肉眼可见的“卡顿感”玩家反而会觉得不稳定。真正要做高精度计时应该用Stopwatch配合自绘循环但那是另一套玩法了对贪吃蛇来说系统Timer足够。4. 常见问题与排查技巧实录4.1 新手最容易踩的四个坑问题现象根本原因解决方法方向键按了没反应方向键被窗体默认导航逻辑吞掉重写ProcessCmdKey方法处理按键蛇身闪烁严重控件没有开启双缓冲设置gamePanel.DoubleBuffered true连续快速按两个方向后蛇反而撞死同帧内多次修改当前方向导致反向使用QueueDirection方向队列每帧只消费一个食物一直生成在蛇身上随机坐标没有排除蛇身占据的格子用while (true)循环重新随机直到坐标不与蛇身重叠第一条是WinForms特有的坑我在开发中至少被问过十次。第二条是绘图类程序通用的问题画质不高级往往就是差一个双缓冲。第三条是我自己写第一个版本时的血泪教训——当时没做方向队列连续按“上左”就会出现蛇瞬间掉头、从自己身体中间穿过去看起来就像开了穿墙挂。第四条属于逻辑严谨性面试官如果问“食物生成在蛇身上怎么办”能答出“坐标排除循环随机”就算过关。4.2 调试技巧和几个值得琢磨的扩展点调试技巧分享几条。第一把GridWidth和GridHeight临时改成5x5这样几秒就能撞墙、吃食物、撞自己不用每次等蛇长到很长才能验证边界逻辑。第二在Step()方法开头加一句Debug.WriteLine(${DateTime.Now:HH:mm:ss.fff} Step)能直观看到定时器实际触发频率排查“是不是Timer不准”这类问题。第三因为这版代码的Game类不依赖UI你可以直接写一个控制台程序调用Game类做单元测试模拟几个场景断言结果这是最能体现“逻辑与界面分离”价值的地方。我发现很多初学者做完基础版就停了其实这里还有不少延伸方向而且每一个都能让你学到新东西加一张障碍物地图、每局随机生成几个障碍方块这练的是二维数组和规则判断做本地最高分排行榜顺便练一下JSON序列化和文件读写把游戏打包成WPF版你会发现换界面层其实没那么难因为整个Game类都是现成的。更进一步这个项目里的事件驱动思想和你以后做C#上位机开发时“串口收到数据触发事件”“扫码枪扫码后触发事件”本质上完全一致理解了Game类和UI解耦你就能理解为什么上位机程序要把通讯逻辑和界面逻辑分开。聊到这儿顺便说一句面试时如果有人问你“C#的委托和事件有什么实际用途”你完全可以拿这个贪吃蛇项目来说——得分通知UI更新就是一个标准的事件应用。我个人最大的体会是贪吃蛇这个项目我写过三遍第一遍抄别人的代码第二遍丢掉参考自己重写第三遍才真正开始想“怎么让代码更清晰”。每一次都有新收获每一次都能发现自己上一遍的代码有多糙。编程这东西看十遍教程不如亲手写一遍代码如果你能把这个项目从头到尾自己做出来再试着把它拆成逻辑层和界面层加上测试你对C#的信心和手感会完全不一样。本文还有配套的精品资源点击获取
返回列表